diff --git a/.cursor/skills/test-t3-desktop b/.cursor/skills/test-t3-desktop new file mode 120000 index 000000000000..5c6b895ffadb --- /dev/null +++ b/.cursor/skills/test-t3-desktop @@ -0,0 +1 @@ +../../.agents/skills/test-t3-desktop \ No newline at end of file diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs index 64d8c8d6c0bd..f55d29c10dc6 100644 --- a/.github/scripts/check-nightly-release.test.cjs +++ b/.github/scripts/check-nightly-release.test.cjs @@ -61,12 +61,6 @@ for (const releaseChannel of ["stable", "nightly"]) { ); assert.equal(calls.length, 0); }); - - test(`allows a manual ${releaseChannel} release from main`, async () => { - const { options, calls } = releaseSourceFixture(); - await assertReleaseSource({ ...options, releaseChannel }); - assert.equal(calls[0].basehead, "candidate...main"); - }); } for (const comparisonStatus of ["behind", "diverged"]) { @@ -79,13 +73,6 @@ for (const comparisonStatus of ["behind", "diverged"]) { }); } -test("accepts a release commit already contained in main", async () => { - for (const comparisonStatus of ["ahead", "identical"]) { - const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" }); - await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }); - } -}); - function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { const calls = []; return { @@ -119,26 +106,6 @@ test("releases the first nightly when no nightly is published", async () => { assert.equal(await shouldReleaseNightly(options), true); }); -test("waits six hours after publication, including manual nightlies", async () => { - for (const age of [0, 3, 6 - 1 / 3600]) { - const { options, calls } = fixture({ releases: [nightly(age)] }); - assert.equal(await shouldReleaseNightly(options), false); - assert.equal(calls.length, 0); - } -}); - -test("releases new commits at six hours and after an idle period", async () => { - for (const age of [6, 7, 24]) { - const { options } = fixture({ releases: [nightly(age)] }); - assert.equal(await shouldReleaseNightly(options), true); - } -}); - -test("skips unchanged commits after the gap", async () => { - const { options } = fixture({ comparisonStatus: "identical" }); - assert.equal(await shouldReleaseNightly(options), false); -}); - test("uses publication time, not release order or the tagged commit date", async () => { const { options } = fixture({ releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], @@ -153,13 +120,6 @@ test("ignores stable releases and drafts when checking the gap", async () => { assert.equal(await shouldReleaseNightly(options), true); }); -test("compares against the published tag, including legacy nightly tags", async () => { - const tag = "nightly-v0.9.0"; - const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); - assert.equal(await shouldReleaseNightly(options), true); - assert.equal(calls[0].basehead, `${tag}...new`); -}); - test("fails instead of releasing when GitHub cannot supply release state", async () => { const { options } = fixture(); options.github.paginate = async () => { @@ -211,8 +171,3 @@ test("stable releases derive the version from legacy nightly tags", async () => }); assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0"); }); - -test("stable releases fail without a published nightly", async () => { - const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] }); - await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/); -}); diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index a85fec9ee04a..5ed6a6ff498a 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -244,10 +244,26 @@ async function resolve({ github, context, core }) { let pullNumber = source.pull_requests?.[0]?.number; if (!pullNumber) { - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: source.head_sha, per_page: 100 }, - ); + let associated; + try { + associated = await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, { + owner, + repo, + commit_sha: source.head_sha, + per_page: 100, + }); + } catch (error) { + // A force-pushed or deleted PR head can disappear before this workflow runs. + if ( + error.status === 422 && + error.response?.data?.message === `No commit found for SHA: ${source.head_sha}` + ) { + core.info(`Skipping CI result for unavailable commit ${source.head_sha}.`); + core.setOutput("publish", "false"); + return; + } + throw error; + } const matchingPulls = associated.filter( (pull) => pull.state === "open" && diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 5b1749fc53f4..753c3eb75907 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -2,7 +2,6 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { - renderComment, resolve, upsertCommentForCurrentHead, validateResult, @@ -53,39 +52,6 @@ test("validates the fixed artifact schema", () => { ); }); -test("renders baseline, impact, ceiling, and ceiling changes", () => { - const baseline = result(); - const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); - current.providers.codex.ceiling = { - ...current.providers.codex.ceiling, - measuredTurnWebSocketWireBytes: 330_000, - }; - const comment = renderComment({ - current, - baseline, - currentRun: { - sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - conclusion: "success", - url: "https://github.com/pingdotgg/t3code/actions/runs/2", - }, - baselineRun: { - sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - matchesBase: true, - url: "https://github.com/pingdotgg/t3code/actions/runs/1", - }, - }); - - assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); - assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); - assert.match(comment, /This PR changes transfer ceilings/); - assert.match(comment, /312\.5 KiB → 322\.3 KiB/); - assert.match(comment, //); - assert.match( - comment, - //, - ); -}); - test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; @@ -216,6 +182,47 @@ test("does not guess when a fallback commit belongs to multiple PRs", async () = assert.equal(fetchedPull, false); }); +test("skips a missing PR commit but preserves other lookup failures", async () => { + for (const [status, message, shouldSkip] of [ + [422, "No commit found for SHA: old-head", true], + [422, "Validation Failed", false], + [403, "API rate limit exceeded", false], + ]) { + const outputs = {}; + const error = Object.assign(new Error(message), { + status, + response: { data: { message } }, + }); + const run = resolve({ + github: { + paginate: async () => { + throw error; + }, + rest: { repos: { listPullRequestsAssociatedWithCommit: () => {} } }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { event: "pull_request", head_sha: "old-head", pull_requests: [] }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + if (shouldSkip) { + await run; + assert.equal(outputs.publish, "false"); + } else { + await assert.rejects(run, (caught) => caught === error); + assert.deepEqual(outputs, {}); + } + } +}); + test("does not report a passing run that skipped the server tests", async () => { const outputs = {}; const listWorkflowRunArtifacts = () => {}; @@ -332,21 +339,3 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); - -test("accepts V2 without comparing it to the V1 scenario", () => { - const current = result(); - current.scenario.id = "thread-transfer-v2"; - current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; - assert.equal(validateResult(current), current); - const comment = renderComment({ - current, - baseline: result(), - currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, - baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, - }); - assert.match(comment, /fixture changed/); - assert.doesNotMatch(comment, /This PR changes transfer ceilings/); - assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); - current.scenario.id = "unrecognized"; - assert.throws(() => validateResult(current), /not supported/); -}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e078c488543..75917c69dddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -135,6 +143,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed id: electron run: vp run --filter @t3tools/desktop ensure:electron @@ -225,6 +241,14 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + # electron's install.js (run by ensure:electron) reads this cache before it + # downloads from GitHub. The key changes only with the Electron version. + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('node_modules/.pnpm/electron@*/node_modules/electron/package.json') }} + - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -255,6 +279,7 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. + # release.yml mirrors this test, test_web, and test_server split. test_web: name: Test Web needs: changes diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml deleted file mode 100644 index 19c82229e98f..000000000000 --- a/.github/workflows/cursor-hygiene-webhook.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Forward to Cursor hygiene - -on: - push: - branches: [main] - # Use trusted workflow code and repository secrets for fork PRs. - # Keep this job payload-only: never check out or run PR code. - pull_request_target: - types: [opened, reopened, ready_for_review] - issues: - types: [opened, closed, reopened] - discussion: - types: [created, closed, reopened] - -permissions: - contents: read - -jobs: - forward: - name: POST to Cursor - runs-on: ubuntu-24.04 - steps: - - name: POST to Cursor - env: - URL: ${{ secrets.CURSOR_T3CODE_WEBHOOK_URL }} - AUTH: ${{ secrets.CURSOR_T3CODE_WEBHOOK_AUTH }} - run: | - set -euo pipefail - if [ -z "${URL:-}" ] || [ -z "${AUTH:-}" ]; then - echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping." - exit 0 - fi - for attempt in 1 2 3; do - curl_status=0 - http_status=$(curl -fsS --max-time 60 -X POST "$URL" \ - --output /dev/null --write-out '%{http_code}' \ - -H "Authorization: $AUTH" \ - -H "Content-Type: application/json" \ - -H "X-GitHub-Event: ${{ github.event_name == 'pull_request_target' && 'pull_request' || github.event_name }}" \ - -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ - --data-binary @"${{ github.event_path }}") || curl_status=$? - if [ "$curl_status" -eq 0 ]; then - exit 0 - fi - if [ "$GITHUB_REF" != "refs/heads/main" ] || \ - [ "$curl_status" -ne 22 ] || [ "$http_status" != "400" ] || \ - [ "$attempt" -eq 3 ]; then - exit "$curl_status" - fi - echo "Cursor webhook returned HTTP 400; retrying in 5 seconds ($attempt/2)." - sleep 5 - done diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 08552d1cd53a..65cfa08321c8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -64,6 +64,13 @@ on: required: false default: false type: boolean + # A runner of the target's own platform and architecture, for a `runner` + # that cannot execute the target. The build job skips everything that + # runs the packaged binaries, and a second job runs it there. + verify_runner: + required: false + default: "" + type: string version: required: true type: string @@ -92,6 +99,11 @@ on: required: true type: string +# electron-builder leaves the default arch (x64) out of the unpacked app's +# directory name. +env: + UNPACKED_DIR: ${{ inputs.platform }}-${{ inputs.arch != 'x64' && format('{0}-', inputs.arch) || '' }}unpacked + permissions: # Windows builds read this run's artifact list to wait for the Linux CLI archive. actions: read @@ -129,50 +141,11 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - # The runner's PowerShell 7.6/.NET 10 intermittently crashes during signing: - # https://github.com/dotnet/runtime/issues/131636 - # Put the .NET 8 release first for both workflow shells and electron-builder. - - name: Use PowerShell 7.4 for Windows ARM builds - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: powershell - run: | - $ErrorActionPreference = "Stop" - $ProgressPreference = "SilentlyContinue" - $version = "7.4.20" - $archive = Join-Path $env:RUNNER_TEMP "PowerShell-$version-win-arm64.zip" - $installDir = Join-Path $env:RUNNER_TEMP "powershell-$version-arm64" - Invoke-WebRequest -UseBasicParsing ` - -Uri "https://github.com/PowerShell/PowerShell/releases/download/v$version/PowerShell-$version-win-arm64.zip" ` - -OutFile $archive - $expectedHash = "67eff749d5dae357501c95a3eec1dcd93f669824e3ca2b1d2adcdecdebf04f35" - if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expectedHash) { - throw "PowerShell archive checksum mismatch" - } - Expand-Archive -Path $archive -DestinationPath $installDir - $installDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - & "$installDir\pwsh.exe" -NoProfile -NonInteractive -Command '$PSVersionTable; [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture' - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Verify Windows ARM PowerShell selection - if: inputs.platform == 'win' && inputs.arch == 'arm64' - shell: pwsh - run: | - $expectedPath = Join-Path $env:RUNNER_TEMP "powershell-7.4.20-arm64\pwsh.exe" - if ((Get-Command pwsh.exe).Source -ne $expectedPath -or $PSVersionTable.PSVersion -ne [version]"7.4.20") { - throw "Windows ARM builds must use the pinned PowerShell runtime" - } - - # Windows installs straight from the registry. There, linking the packages - # takes the time, not downloading them, so a ~700 MB package cache did not - # pay for its own restore. Across recent nightlies, restore plus install - # took 183-233s on arm64 and 116-172s on x64, while a plain install took - # 142-210s and 84-160s. The arm64 entries also filled 4 GB of the - # repository's 10 GB Actions cache. - name: Setup Vite+ uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json - cache: ${{ inputs.platform != 'win' }} + cache: true run-install: false # pnpm checks the lockfile and policy before reusing this result. A missing @@ -241,25 +214,145 @@ jobs: name: js-bundle path: apps - # Only the resource monitor build needs these libraries. With a cached - # monitor, the desktop build's preflight skips its MSVC check, and node-pty - # ships prebuilt Windows binaries, so the 30-60s install is skipped too. - - name: Install Spectre-mitigated MSVC libs + - name: Install cargo-xwin if: inputs.platform == 'win' && steps.resource_monitor_cache.outputs.cache-hit != 'true' - shell: pwsh + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + with: + tool: cargo-xwin@0.23.1 + + # Built here rather than inside the desktop build so the CLI archive, + # which bundles the same binary, can be built and uploaded first. + - name: Build resource monitor + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + shell: bash + run: cargo ${{ inputs.platform == 'win' && 'xwin build' || 'build' }} --locked --release --manifest-path native/resource-monitor/Cargo.toml --target "${{ inputs.rust_target }}" + + # Off Windows, scripts/lib/windows-jsign.ts signs through jsign instead of + # the TrustedSigning PowerShell module. + - name: Install jsign + if: inputs.platform == 'win' + shell: bash run: | - $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Runtimes.${{ inputs.arch == 'arm64' && 'ARM64' || 'x86.x64' }}.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } + set -euo pipefail + dir="$RUNNER_TEMP/jsign" + mkdir -p "$dir" + curl -fsSL --retry 3 -o "$dir/jsign.jar" https://github.com/ebourg/jsign/releases/download/7.6/jsign-7.6.jar + echo "6cf58aa080e7ff82656f320cff8d4a3bff30408df8766806dcba3740dd839fa5 $dir/jsign.jar" | sha256sum -c - + printf '#!/bin/sh\nexec java -jar "%s" "$@"\n' "$dir/jsign.jar" > "$dir/jsign" + chmod +x "$dir/jsign" + echo "T3CODE_JSIGN=$dir/jsign" >> "$GITHUB_ENV" + + # The desktop build applies the same branding to apps/server/dist/client + # (resolveDesktopWebAssetBrand in scripts/build-desktop-artifact.ts); the + # CLI archive below ships that client and is now built first. + - name: Apply web client branding + if: inputs.cli_archive + shell: bash + run: | + brand=production + if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi + node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + + # The single-executable is built with a Node that supports --build-sea + # (25.7+); the repo itself stays on the engines.node version. A native + # build injects into the runner's own Node; a Windows target downloads its + # node.exe. + - name: Build CLI single-executable + if: inputs.cli_archive + shell: bash + env: + # The exact version, not a major: vp downloads it from nodejs.org/dist on + # the runner, and only exact versions have a dist directory. Keep in + # step with SEA_NODE_VERSION in apps/server/vite.config.ts. + VP_NODE_VERSION: "26.8.2" + # The executable bundles the server again, so it needs the same stamp + # as release.yml's JS bundle. + T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} + run: node apps/server/scripts/cli.ts build-exe --verbose ${{ inputs.platform == 'win' && format('--target win-{0}', inputs.arch) || '' }} + + - name: Import macOS signing certificate for the CLI archive + if: inputs.cli_archive && inputs.platform == 'mac' + shell: bash + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then + echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." + exit 0 + fi + keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" + keychain_password="$(openssl rand -hex 16)" + cert_path="$RUNNER_TEMP/t3-cli-signing.p12" + printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" + if [[ -z "$identity" ]]; then + echo "No Developer ID Application identity found in CSC_LINK." >&2 + exit 1 + fi + echo "::add-mask::$keychain_password" + echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + echo "macOS CLI signing enabled." + + - name: Stage resource monitor for the CLI archive + if: inputs.cli_archive + shell: bash + run: | + set -euo pipefail + binary_name="t3-resource-monitor" + if [[ "${{ inputs.platform }}" == "win" ]]; then + binary_name="${binary_name}.exe" + fi + target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" + mkdir -p "$target_dir" + cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + + - name: Build CLI archive + if: inputs.cli_archive + shell: bash + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + run: | + set -euo pipefail + if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then + key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + fi + node scripts/build-cli-archive.ts \ + --platform "${{ inputs.platform }}" \ + --arch "${{ inputs.arch }}" \ + --version "${{ inputs.version }}" \ + --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ + --output-dir release-cli + + - name: Smoke-test CLI archive + if: inputs.cli_archive && inputs.verify_runner == '' + shell: bash + run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" + + - name: Upload CLI archive + if: inputs.cli_archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli/* + if-no-files-found: error - uses: ./.github/actions/setup-apt-mirrors if: inputs.platform == 'linux' @@ -280,68 +373,6 @@ jobs: convert -version fi - - name: Prepare Azure Trusted Signing - if: inputs.platform == 'win' - shell: pwsh - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} - run: | - $ErrorActionPreference = "Stop" - - $requiredSecrets = @( - $env:AZURE_TENANT_ID, - $env:AZURE_CLIENT_ID, - $env:AZURE_CLIENT_SECRET, - $env:AZURE_TRUSTED_SIGNING_ENDPOINT, - $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME, - $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME, - $env:AZURE_TRUSTED_SIGNING_PUBLISHER_NAME - ) - if ($requiredSecrets | Where-Object { [string]::IsNullOrWhiteSpace($_) }) { - Write-Host "Azure Trusted Signing disabled; skipping TrustedSigning module preparation." - exit 0 - } - - try { - Install-PackageProvider ` - -Name NuGet ` - -MinimumVersion 2.8.5.201 ` - -Force ` - -Scope CurrentUser ` - -ErrorAction Stop - } catch { - Write-Warning "Could not bootstrap NuGet package provider. Continuing because the runner may already have a usable provider. $($_.Exception.Message)" - } - - Install-Module ` - -Name TrustedSigning ` - -MinimumVersion 0.5.0 ` - -Force ` - -AllowClobber ` - -Repository PSGallery ` - -Scope CurrentUser ` - -ErrorAction Stop - - Import-Module TrustedSigning -MinimumVersion 0.5.0 -Force - Get-Command Invoke-TrustedSigning -ErrorAction Stop - - $moduleRoots = @( - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "PowerShell", "Modules"), - [System.IO.Path]::Combine([Environment]::GetFolderPath("MyDocuments"), "WindowsPowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "PowerShell", "Modules"), - [System.IO.Path]::Combine($env:ProgramFiles, "WindowsPowerShell", "Modules") - ) - $modulePathEntries = @($moduleRoots + ($env:PSModulePath -split ";")) | - Where-Object { $_ -and (Test-Path $_) } | - Select-Object -Unique - "PSModulePath=$($modulePathEntries -join ';')" >> $env:GITHUB_ENV - # The WSL backend runs the Linux CLI archive inside the distro, so the # Windows desktop embeds the same-arch archive the release attaches. # This job starts alongside that Linux job instead of after it, and the @@ -407,7 +438,7 @@ jobs: shell: bash env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -433,6 +464,9 @@ jobs: --skip-build --verbose ) + if [[ -n "${{ inputs.verify_runner }}" ]]; then + args+=(--keep-stage) + fi has_all() { for value in "$@"; do @@ -488,107 +522,35 @@ jobs: vp run dist:desktop:artifact "${args[@]}" - # The single-executable is built with a Node that supports --build-sea - # (25.7+); the repo itself stays on the engines.node version. It always - # injects into the runner's own Node: tsdown's cross-target download path - # runs `tar` on a drive-letter path on Windows, which GNU tar reads as a - # remote host, and a cross-built macOS binary cannot be smoke-tested. - - name: Build CLI single-executable - if: inputs.cli_archive - shell: bash - env: - # The exact version, not a major: vp downloads it from nodejs.org/dist on - # the runner, and only exact versions have a dist directory. Keep in - # step with SEA_NODE_VERSION in apps/server/vite.config.ts. - VP_NODE_VERSION: "26.8.2" - # The executable bundles the server again, so it needs the same stamp - # as release.yml's JS bundle. - T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && inputs.release_channel || 'dev' }} - run: node apps/server/scripts/cli.ts build-exe --verbose - - - name: Import macOS signing certificate for the CLI archive - if: inputs.cli_archive && inputs.platform == 'mac' + # The verify job runs what this host could not: the packaged natives and + # the CLI. Its scripts are bundled so that job needs no install. + - name: Stage verification inputs + if: inputs.verify_runner != '' shell: bash - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} run: | set -euo pipefail - if [[ -z "$CSC_LINK" || -z "$CSC_KEY_PASSWORD" ]]; then - echo "macOS CLI signing disabled (missing CSC_LINK); the archive is signed ad hoc." - exit 0 - fi - keychain="$RUNNER_TEMP/t3-cli-signing.keychain-db" - keychain_password="$(openssl rand -hex 16)" - cert_path="$RUNNER_TEMP/t3-cli-signing.p12" - printf '%s' "$CSC_LINK" | base64 --decode > "$cert_path" - security create-keychain -p "$keychain_password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$cert_path" -k "$keychain" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" >/dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -n 1)" - if [[ -z "$identity" ]]; then - echo "No Developer ID Application identity found in CSC_LINK." >&2 - exit 1 - fi - echo "::add-mask::$keychain_password" - echo "T3CODE_CLI_MAC_SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - echo "macOS CLI signing enabled." - - - name: Stage resource monitor for the CLI archive - if: inputs.cli_archive - shell: bash - run: | - set -euo pipefail - binary_name="t3-resource-monitor" - if [[ "${{ inputs.platform }}" == "win" ]]; then - binary_name="${binary_name}.exe" - fi - target_dir="$RUNNER_TEMP/cli-resource-monitor/${{ inputs.resource_key }}" - mkdir -p "$target_dir" - cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" - - - name: Build CLI archive - if: inputs.cli_archive - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - run: | - set -euo pipefail - if [[ "${{ inputs.platform }}" == "mac" && -n "${APPLE_API_KEY:-}" ]]; then - key_path="$RUNNER_TEMP/AuthKey_cli_${APPLE_API_KEY_ID}.p8" - printf '%s' "$APPLE_API_KEY" > "$key_path" - export APPLE_API_KEY="$key_path" - fi - node scripts/build-cli-archive.ts \ - --platform "${{ inputs.platform }}" \ - --arch "${{ inputs.arch }}" \ - --version "${{ inputs.version }}" \ - --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \ - --output-dir release-cli - - - name: Smoke-test CLI archive - if: inputs.cli_archive - shell: bash - run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ inputs.version }}" - - - name: Upload CLI archive - if: inputs.cli_archive + mkdir -p verify + unpacked=$(ls -d "${TMPDIR:-/tmp}"/t3code-desktop-${{ inputs.platform }}-stage-*/app/dist/"$UNPACKED_DIR") + tar -C "$(dirname "$unpacked")" -cf verify/unpacked.tar "$(basename "$unpacked")" + esbuild=scripts/node_modules/.bin/esbuild + banner="import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);" + # build-desktop-artifact.ts's own import.meta.main guard must not run + # its CLI inside the verify bundle. + "$esbuild" scripts/verify-windows-desktop-artifact.ts --bundle --platform=node --format=esm \ + --target=node24 --define:import.meta.main=false --banner:js="$banner" \ + --outfile=verify/verify-windows-desktop-artifact.mjs + "$esbuild" scripts/smoke-cli-archive.ts --bundle --platform=node --format=esm \ + --target=node24 --banner:js="$banner" --outfile=verify/smoke-cli-archive.mjs + + - name: Upload verification inputs + if: inputs.verify_runner != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: cli-${{ inputs.platform }}-${{ inputs.arch }} - path: release-cli/* + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify/* if-no-files-found: error + retention-days: 1 + compression-level: 1 - name: Collect release assets shell: bash @@ -661,3 +623,81 @@ jobs: name: resource-monitor-${{ inputs.resource_key }} path: resource-monitor-publish/${{ inputs.resource_key }}/* if-no-files-found: error + + verify: + name: Verify + needs: [build] + if: inputs.verify_runner != '' + runs-on: ${{ inputs.verify_runner }} + timeout-minutes: 15 + steps: + - name: Download verification inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verify-${{ inputs.platform }}-${{ inputs.arch }} + path: verify + + - name: Download CLI archive + if: inputs.cli_archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-${{ inputs.platform }}-${{ inputs.arch }} + path: release-cli + + - name: Verify packaged natives + shell: bash + run: | + set -euo pipefail + tar -C verify -xf verify/unpacked.tar + node verify/verify-windows-desktop-artifact.mjs \ + --app-dir "$(cygpath -w "$PWD/verify/$UNPACKED_DIR")" \ + --arch "${{ inputs.arch }}" --build-version "${{ inputs.version }}" --verbose + + - name: Smoke-test CLI archive + if: inputs.cli_archive + shell: bash + run: node verify/smoke-cli-archive.mjs --archive release-cli/* --expect-version "${{ inputs.version }}" + + # cargo-xwin links this binary on Linux, and nothing else runs it before users do. + # Node rather than pwsh: PowerShell on Windows ARM crashes intermittently + # (https://github.com/dotnet/runtime/issues/131636), which is why this + # target stopped building on Windows. + - name: Smoke-test resource monitor + shell: bash + env: + MONITOR: verify/${{ env.UNPACKED_DIR }}/resources/resource-monitor/t3-resource-monitor.exe + EXPECTED_ARCH: ${{ inputs.arch == 'arm64' && 'aarch64' || 'x86_64' }} + run: | + node --input-type=module <<'EOF' + import { spawn } from "node:child_process"; + const monitor = spawn(process.env.MONITOR, { stdio: ["pipe", "pipe", "inherit"] }); + monitor.stdout.setEncoding("utf8"); + let output = ""; + const exited = new Promise((resolve) => monitor.on("close", resolve)); + const hello = await new Promise((resolve, reject) => { + monitor.on("error", reject); + monitor.on("close", () => reject(new Error(`Resource monitor exited before hello: ${output}`))); + monitor.stdout.on("data", (chunk) => { + output += chunk; + const end = output.indexOf("\n"); + if (end !== -1) resolve(JSON.parse(output.slice(0, end))); + }); + }); + if (hello.type !== "hello" || hello.arch !== process.env.EXPECTED_ARCH) { + throw new Error(`Unexpected hello: ${JSON.stringify(hello)}`); + } + const send = (message) => monitor.stdin.write(`${JSON.stringify({ version: hello.version, ...message })}\n`); + send({ type: "configure", rootPid: process.pid, sampleIntervalMs: 1000 }); + send({ type: "sampleNow", requestId: "verify" }); + monitor.stdin.end(); + const exitCode = await exited; + const sample = output + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) + .find((message) => message.requestId === "verify"); + if (exitCode !== 0 || sample?.type !== "snapshot" || !sample.processes.some((p) => p.pid === process.pid)) { + throw new Error(`Resource monitor exited ${exitCode} with: ${output}`); + } + console.log(`Resource monitor sampled ${sample.scannedProcessCount} processes.`); + EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0e60fdfc75d..65ea056e85a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,7 +256,7 @@ jobs: run: vp run typecheck # Keep tests on their own runners, using the same package split and server - # shards as CI, so the release check job does not spend its budget on tests. + # shards as CI (test, test_web, test_server in ci.yml). Keep the two in sync. test: name: Release tests needs: [preflight] @@ -286,23 +286,67 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential - # The web package's browser project runs React tests in headless Chromium. + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test + + # Web browser tests time out when they share a runner with the other package + # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. + test_web: + name: Release web tests + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + + - name: Resolve Playwright version + id: playwright_version + run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Chromium + id: chromium_cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }} + - name: Install Chromium for web browser tests - run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium + if: steps.chromium_cache.outputs.cache-hit != 'true' + run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium + + - name: Install missing Chromium system dependencies + run: | + browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit) + test -n "$browser" + if ldd "$browser" | grep '=> not found'; then + node apps/web/node_modules/playwright/cli.js install-deps chromium + fi - name: Test - run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + run: vp run --filter @t3tools/web test + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - name: Checkout uses: actions/checkout@v6 @@ -529,8 +573,9 @@ jobs: if-no-files-found: error retention-days: 1 - # One job per platform and architecture (see release-desktop.yml), each on - # hardware of its own architecture, and each gated only on the JS bundle. + # One job per platform and architecture (see release-desktop.yml), each + # gated only on the JS bundle. Windows targets build on Linux and run their + # native checks on a Windows runner of their own architecture. # The Windows jobs also embed the same-arch Linux CLI archive as the WSL # runtime; they wait for that artifact partway through instead of for the # whole Linux job. Every job builds the desktop app; all but macOS x64 also @@ -633,9 +678,11 @@ jobs: resource_key: linux-arm64 cli_archive: true - # The Windows jobs start with the other platforms. Their checkout, install, - # and toolchain setup take longer than the same-arch Linux job needs to - # upload its CLI archive, so the wait inside is usually instant. + # The Windows jobs start with the other platforms and wait for the same-arch + # Linux CLI archive after their other setup. Packaging is mostly CPU and + # small-file work, which Linux runners do faster than Windows ones, so both + # Windows targets build and sign on Linux and only the native checks run on + # Windows. desktop_win_x64: name: Desktop Windows x64 needs: [preflight, relay_public_config, build_bundle] @@ -651,7 +698,8 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: blacksmith-16vcpu-ubuntu-2404 + verify_runner: windows-2025 platform: win target: nsis arch: x64 @@ -674,7 +722,8 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Windows arm64 - runner: windows-11-arm + runner: blacksmith-16vcpu-ubuntu-2404 + verify_runner: windows-11-arm platform: win target: nsis arch: arm64 @@ -690,6 +739,7 @@ jobs: relay_public_config, quality, test, + test_web, test_server, desktop_mac_arm64, desktop_mac_x64, @@ -698,7 +748,7 @@ jobs: desktop_win_x64, desktop_win_arm64, ] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.test.result == 'success' && needs.test_web.result == 'success' && needs.test_server.result == 'success' && needs.desktop_mac_arm64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.desktop_linux_x64.result == 'success' && needs.desktop_linux_arm64.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_win_arm64.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 permissions: diff --git a/.grok/skills/test-t3-desktop b/.grok/skills/test-t3-desktop new file mode 120000 index 000000000000..5c6b895ffadb --- /dev/null +++ b/.grok/skills/test-t3-desktop @@ -0,0 +1 @@ +../../.agents/skills/test-t3-desktop \ No newline at end of file diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000000..ff75832369d8 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,21 @@ +// Windows WSL dev mode runs the server under Linux from the same node_modules +// as the Windows client, so Windows installs also need Linux x64 glibc native +// modules. Other platforms install only their own binaries; setting this +// everywhere added ~2.2 GB of foreign binaries to every macOS install. +// Release scripts stage their own installs with explicit architectures. +module.exports = { + hooks: { + updateConfig(config) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- pnpm loads this file before any Effect runtime exists. + if (process.platform !== "win32") return config; + return { + ...config, + supportedArchitectures: { + os: ["current", "linux"], + cpu: ["current", "x64"], + libc: ["current", "glibc"], + }, + }; + }, + }, +}; diff --git a/AGENTS.md b/AGENTS.md index 8a7c3027b80b..f7e4dd0f3720 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,28 +78,58 @@ The most common defect in this repo is a change that works on the path you teste ## Dev servers - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- Setup runs in the background. While `.t3/setup-running` names a live PID, wait for it and do not run `vp i`. It ends with `.t3/setup-done` or `.t3/setup-failed` (with the error tail). With no marker at all, setup never ran here, so run `vp i` yourself. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. - Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. - To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. +- Need a second checkout? `git worktree add` it next to this worktree, not in `/tmp`. Remove it when done. ## Test data An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- Run `vp run migrate-dev-db` with your dev server stopped. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. ## Verifying -- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- Smallest proof that the change works. `vp test run ` for the tests you touched (`-t ""` in big files), `vp lint `, and `vp run typecheck` in the package you changed. Not `npx tsc`; it is 3x slower than tsc-rs. +- Run `vp run knip:check` (about 35 s) when you add, remove, or rename exports, files, or dependencies. +- Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. - Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. +- The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: + - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. + - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. + - No per-test timeout argument or `retry`. The project timeout is the ceiling, and flaky tests are fixed, not retried. + - No `performance.now()` or `Date.now()` deltas. Assert on events and counts, not elapsed time. + - Browser tests assert through `expect.element` or `vi.waitFor`, never `requestAnimationFrame` or `transitionend`. + - `vi.mock` factories must not spread a real module that pulls in Shiki or the diff workers. + - `it.skip` and friends need a tracking issue on the line above: `// quarantine: #1234 `. + - Lint enforces this: `t3code/no-test-timeout-or-unreasoned-skip`, `t3code/no-frame-waits-in-browser-tests`, `t3code/no-wall-clock-in-tests`, `t3code/no-real-time-wait-in-tests`, `t3code/no-polling-loop-in-tests`. +- Web browser tests are `*.browser.test.tsx` or `*.browser.test.ts` and render with `await using screen = await render(...)` from `apps/web/test/render.tsx`. `t3code/require-disposable-render` errors on the old patterns. + - Acquire what the test drives after mounting (fake timers it advances, spies it sets up after mounting) after the render, so it disposes before the unmount. Anything the first render reads comes before it. + - A helper that renders and then waits or clicks puts the screen in an `AsyncDisposableStack` and `move()`s it out only on success, so a failed wait still unmounts. + - `rerender` needs a fresh element on every call. + - No lifecycle hooks and no module-level mutable state. Shared mock state that `vi.mock` factories read is reset by a `setup()` call at the top of the test. + - `router` is on by default. Pass `router: false` only with a one-line comment saying why. + + ```tsx + it("saves on Enter", async () => { + const onSave = vi.fn(); + using _now = vi.spyOn(Date, "now").mockReturnValue(0); + await using screen = await render(, { router: "/threads/1" }); + await userEvent.type(screen.getByRole("textbox"), "Title{Enter}"); + expect(onSave).toHaveBeenCalledWith("Title"); + }); + ``` + - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. @@ -112,7 +142,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. - One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. -- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +- When babysitting: call `watch_pull_request` and end your turn. T3 Code wakes you on checks, comments, or conflicts. Do not poll or sleep. Verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation diff --git a/apps/desktop/gnome-extension/captureFeedback.test.js b/apps/desktop/gnome-extension/captureFeedback.test.js index 501f2586f1bf..fb6003bf3970 100644 --- a/apps/desktop/gnome-extension/captureFeedback.test.js +++ b/apps/desktop/gnome-extension/captureFeedback.test.js @@ -151,17 +151,6 @@ it("waits for T3 to remap and cancels that wait when disabled", async () => { expect(shell.mapped.size).toBe(0); }); -it("does not animate under reduced motion but still activates T3", async () => { - shell.animations = false; - shell.windows = [target()]; - const { feedback, started } = begin(); - expect(started).toBe(false); - expect(shell.actors).toHaveLength(0); - await feedback.activate(":1.23", "T3"); - expect(shell.activate).toHaveBeenCalledOnce(); - feedback.dispose(); -}); - it("rejects another sender and locked-session activation", async () => { shell.windows = [target()]; const { feedback } = begin(); diff --git a/apps/desktop/gnome-extension/captureService.test.js b/apps/desktop/gnome-extension/captureService.test.js index b8313f920f72..fa45cd604a0d 100644 --- a/apps/desktop/gnome-extension/captureService.test.js +++ b/apps/desktop/gnome-extension/captureService.test.js @@ -28,11 +28,6 @@ it("rejects other clients before looking at the active window", async () => { expect(await service.capture(":1.23")).toBe("pixels"); }); -it.each(CLIENT_NAMES)("accepts the current owner of %s", async (client) => { - const { service } = fixture({ getNameOwner: async (name) => (name === client ? ":1.23" : null) }); - expect(await service.capture(":1.23")).toBe("pixels"); -}); - it("rechecks the name owner on each capture", async () => { let owner = ":1.23"; const { service } = fixture({ getNameOwner: async () => owner }); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d78ac0fd286a..c652ccabc768 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,13 +1,12 @@ { "name": "@t3tools/desktop", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" }, @@ -24,7 +23,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.4.5", + "electron": "44.9.3", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", @@ -38,7 +37,7 @@ "@types/node": "catalog:", "acorn": "8.16.0", "cross-env": "^10.1.0", - "electron-builder": "26.15.6", + "electron-builder": "26.16.1", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 780cf639fd18..0fb4377f07be 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -94,7 +94,7 @@ function runChecked(command, args) { throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); } -export function resolveMacCodeSignArguments(appBundlePath) { +function resolveMacCodeSignArguments(appBundlePath) { return ["--force", "--deep", "--sign", "-", "--timestamp=none", appBundlePath]; } @@ -201,7 +201,7 @@ function registerMacLauncherBundle(appBundlePath) { } // Bundle-internal paths are macOS paths whatever host builds them. -export function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { +function resolveMacLauncherIconPaths(runtimeDir, development = isDevelopment) { return { sourceIconPath: development ? developmentMacIconPngPath : productionMacIconPngPath, generatedIconPath: NodePath.posix.join( @@ -260,7 +260,7 @@ function ensureMacIconIcns(runtimeDir) { } } -export function resolveMacBundleInfoPlistStrings(executableName) { +function resolveMacBundleInfoPlistStrings(executableName) { return { CFBundleDisplayName: APP_DISPLAY_NAME, CFBundleName: APP_DISPLAY_NAME, diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 2b1dc2cb3fb2..fa6e56b5800e 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -11,9 +11,6 @@ import { makeDevelopmentEnvironmentScript, makeDevelopmentLauncherScript, resolveElectronBinaryPath, - resolveMacBundleInfoPlistStrings, - resolveMacCodeSignArguments, - resolveMacLauncherIconPaths, resolveMacLauncherPaths, writeDevelopmentLauncherScript, } from "./electron-launcher.mjs"; @@ -106,30 +103,6 @@ describe("electron development launcher", () => { assert.notInclude(script, "node_modules/electron"); }); - it("declares why the macOS app needs protected access", () => { - const values = resolveMacBundleInfoPlistStrings("T3 Code (Dev) Launcher"); - - assert.equal( - values.NSScreenCaptureUsageDescription, - "T3 Code captures the active window when you use the snapshot shortcut.", - ); - assert.equal( - values.NSDocumentsFolderUsageDescription, - "T3 Code reads project files you open in the desktop app.", - ); - }); - - it("ad-hoc signs the complete development app bundle", () => { - assert.deepEqual(resolveMacCodeSignArguments("/runtime/T3 Code (Dev).app"), [ - "--force", - "--deep", - "--sign", - "-", - "--timestamp=none", - "/runtime/T3 Code (Dev).app", - ]); - }); - it("restores execute permissions on an unchanged launcher", () => { const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-launcher-")); const launcherPath = NodePath.join(directory, "launcher"); @@ -143,16 +116,6 @@ describe("electron development launcher", () => { NodeFS.rmSync(directory, { recursive: true, force: true }); } }); - - it("derives launcher icons from canonical development and production assets", () => { - const development = resolveMacLauncherIconPaths("/runtime", true); - const production = resolveMacLauncherIconPaths("/runtime", false); - - assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); - assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); - assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); - assert.equal(production.generatedIconPath, "/runtime/icon-prod.icns"); - }); }); async function startupCleanupPattern(desktopDir) { diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index b8b8254c9b3c..933a763dfa35 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -116,13 +116,39 @@ function runChecked(command, args) { ); } -function installElectronRuntime(electronDir, version) { +// electron's own installer goes through @electron/get, which reuses its +// download cache (~/.cache/electron, ~/Library/Caches/electron) and verifies +// the bundled checksums. pnpm 11 never runs it because electron has no +// install script, so a new worktree would otherwise download 130 MB again. +function installFromElectronCache(electronDir) { + const result = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(electronDir, "install.js")], + { stdio: ["ignore", "inherit", "inherit"] }, + ); + if (result.status === 0) { + return true; + } + process.stderr.write( + `electron install.js failed with exit code ${result.status ?? "unknown"}, downloading with curl instead\n`, + ); + return false; +} + +function downloadElectronRuntime(electronDir, version) { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-electron-")); const zipPath = NodePath.join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`); try { runChecked("curl", [ "-fsSL", + "--retry", + "3", + "--retry-all-errors", + "--connect-timeout", + "30", + "--max-time", + "300", `https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`, "-o", zipPath, @@ -142,21 +168,27 @@ function installElectronRuntime(electronDir, version) { } } +function resetRuntime(electronDir) { + NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); +} + export function ensureElectronRuntime() { const electronPackageJsonPath = require.resolve("electron/package.json"); const electronPackageJson = JSON.parse(NodeFS.readFileSync(electronPackageJsonPath, "utf8")); const electronDir = NodePath.dirname(electronPackageJsonPath); const platformPath = getPlatformPath(); const electronPath = NodePath.join(electronDir, "dist", platformPath); - const missingBeforeInstall = missingRuntimePaths(electronDir, platformPath); - const invalidBeforeInstall = invalidRuntimePaths(electronDir, platformPath); - - if (missingBeforeInstall.length > 0 || invalidBeforeInstall.length > 0) { - if (NodeFS.existsSync(NodePath.join(electronDir, "dist"))) { - NodeFS.rmSync(NodePath.join(electronDir, "dist"), { recursive: true, force: true }); + const needsInstall = () => + missingRuntimePaths(electronDir, platformPath).length > 0 || + invalidRuntimePaths(electronDir, platformPath).length > 0; + + if (needsInstall()) { + resetRuntime(electronDir); + if (!installFromElectronCache(electronDir) || needsInstall()) { + resetRuntime(electronDir); + downloadElectronRuntime(electronDir, electronPackageJson.version); } - NodeFS.rmSync(NodePath.join(electronDir, "path.txt"), { force: true }); - installElectronRuntime(electronDir, electronPackageJson.version); } const missingAfterInstall = missingRuntimePaths(electronDir, platformPath); diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index d2055e2c6817..8af6ae00c3f9 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -60,12 +60,6 @@ describe("desktop preload bundle verifier", () => { ); }); - it("ignores import-like text in strings", () => { - assert.doesNotThrow(() => - verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`), - ); - }); - it("rejects unsupported require calls with comments before the opening parenthesis", () => { assert.throws( () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`), @@ -73,13 +67,6 @@ describe("desktop preload bundle verifier", () => { ); }); - it("rejects unsupported optional require calls", () => { - assert.throws( - () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`), - /unsupported sandbox imports: node:fs/, - ); - }); - it("accepts Electron sandbox module aliases", () => { assert.doesNotThrow(() => verifyPreloadBundle(` diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index f47d4fef1975..9602ede4ec27 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -148,14 +148,13 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); -export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( +const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( function* (): Effect.fn.Return { const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; yield* Effect.forEach( instances, - // Exceeds the backend's 30s graceful termination and output drain budget. - (instance) => instance.stop({ timeout: Duration.seconds(45) }), + (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); }, @@ -243,7 +242,9 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.showConnectingSplash; + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); yield* appActivation.start.pipe( diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index 6e0c76842f24..2bf1e716a99a 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -81,46 +81,6 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { } describe("desktop app control server", () => { - it.effect("roundtrips a request and removes its socket on shutdown", () => - Effect.gen(function* () { - const platform = yield* HostProcess.Platform; - const userId = yield* HostProcess.UserId; - yield* Effect.promise(async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); - const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); - const received: DesktopAppActivationRequest[] = []; - const server = await startDesktopAppControlServer({ - ...target, - userId, - handle: async (input) => { - received.push(input); - return { - version: 1, - requestId: input.requestId, - ok: true, - projectId: ProjectId.make("project-1"), - threadId: ThreadId.make("thread-1"), - }; - }, - cancel: () => undefined, - onReclaimError: () => undefined, - }); - openServers.push(server); - - const response = await exchange(target.address, request("request-1", platform)); - - expect(received).toHaveLength(1); - expect(response).toMatchObject({ ok: true, requestId: "request-1" }); - await server.close(); - openServers.splice(openServers.indexOf(server), 1); - if (target.directory !== null) { - await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); - } - await NodeFSP.rm(root, { recursive: true, force: true }); - }); - }), - ); - it.effect("cancels a queued request when the client disconnects", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts index 7a889c2e91d3..ab20c7610909 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -49,44 +49,6 @@ describe("DesktopAppActivationBroker", () => { broker.close(); }); - it("queues requests after unsubscribe until a new renderer registers", async () => { - const previousSend = vi.fn(); - const nextSend = vi.fn(); - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - broker.registerRenderer(previousSend); - broker.clearRenderer(); - - const response = broker.request(request); - expect(previousSend).not.toHaveBeenCalled(); - expect(nextSend).not.toHaveBeenCalled(); - - broker.registerRenderer(nextSend); - expect(nextSend).toHaveBeenCalledWith(request); - broker.complete({ - version: 1, - requestId: request.requestId, - ok: true, - projectId: ProjectId.make("project-1"), - threadId: ThreadId.make("thread-1"), - }); - - await expect(response).resolves.toMatchObject({ ok: true }); - broker.close(); - }); - - it("removes a queued request when its CLI connection closes", async () => { - const send = vi.fn(); - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - - const response = broker.request(request); - broker.cancel(request.requestId); - broker.registerRenderer(send); - - await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); - expect(send).not.toHaveBeenCalled(); - broker.close(); - }); - it("never sends a canceled request that was queued behind another request", async () => { const send = vi.fn(); const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); @@ -112,19 +74,4 @@ describe("DesktopAppActivationBroker", () => { expect(send).toHaveBeenCalledTimes(1); broker.close(); }); - - it("times out a request without polling", async () => { - vi.useFakeTimers(); - try { - const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); - const response = broker.request(request); - - await vi.advanceTimersByTimeAsync(1_000); - - await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); - broker.close(); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 1cd8485a1ec9..32e7b3b3118d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -14,7 +14,6 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopUserData from "./DesktopUserData.ts"; const defaultEnvironmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -176,66 +175,6 @@ describe("DesktopAppIdentity", () => { ), ); - it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Dev)"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - description: "permission denied", - pathOrDescriptor: legacyPath, - }); - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - - assert.instanceOf(error, DesktopUserData.DesktopUserDataInitializationError); - assert.equal(error.resourcePath, legacyPath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Could not initialize Electron user data during inspect at ${legacyPath} (PermissionDenied).`, - ); - }), - { - legacyPathProbeError: cause, - environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, - }, - ); - }); - - it.effect("configures app identity from the environment commit override", () => { - const calls: ElectronAppCalls = { - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; - - assert.deepEqual(calls.setName, ["T3 Code Beta"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Beta)"); - assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); - assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); - assert.deepEqual(calls.setDockIcon, []); - }), - { - calls, - environment: { - env: { - T3CODE_COMMIT_HASH: "0123456789abcdef", - }, - }, - pngIconPath: Option.some("/icon.png"), - }, - ); - }); - it.effect.each([ { stage: "Beta", environment: {} }, { @@ -267,26 +206,4 @@ describe("DesktopAppIdentity", () => { { calls, environment }, ); }); - - it.effect("sets the dock icon only when running unpackaged", () => { - const calls: ElectronAppCalls = { - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; - - assert.deepEqual(calls.setDockIcon, ["/icon.png"]); - }), - { - calls, - environment: { isPackaged: false }, - pngIconPath: Option.some("/icon.png"), - }, - ); - }); }); diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts deleted file mode 100644 index 6d2179f0256a..000000000000 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as NodePath from "@effect/platform-node/NodePath"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; - -import * as DesktopAssets from "./DesktopAssets.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -const layerEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: "/Users/alice", - platform: "darwin", - processArch: "arm64", - appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", - isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", - runningUnderArm64Translation: false, -}).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, NodePath.layerPosix, DesktopConfig.layerTest({})), - ), -); - -describe("DesktopAssets", () => { - it.effect("uses canonical source-tree icons for unpackaged development", () => - Effect.gen(function* () { - const layerDevelopmentEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: "/Users/alice", - platform: "linux", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: false, - resourcesPath: "/repo/apps/desktop/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - NodePath.layerPosix, - DesktopConfig.layerTest({ VITE_DEV_SERVER_URL: "http://localhost:5733" }), - ), - ), - ); - const layerFileSystem = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(String(path).includes("/assets/dev/")), - }); - const assets = yield* DesktopAssets.DesktopAssets.pipe( - Effect.provide( - DesktopAssets.layer.pipe( - Layer.provide(Layer.merge(layerFileSystem, layerDevelopmentEnvironment)), - ), - ), - ); - - const icons = yield* assets.iconPaths; - - assert.match(Option.getOrThrow(icons.ico), /assets\/dev\/blueprint-windows\.ico$/); - assert.match(Option.getOrThrow(icons.png), /assets\/dev\/blueprint-universal-1024\.png$/); - assert.isTrue(Option.isNone(icons.icns)); - }), - ); - - it.effect("preserves the failed asset candidate and filesystem cause", () => - Effect.gen(function* () { - const fileName = "custom.bin"; - const candidatePath = "/repo/apps/desktop/resources/custom.bin"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - pathOrDescriptor: candidatePath, - description: "private filesystem diagnostic", - }); - const layerFileSystem = FileSystem.layerNoop({ - exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), - }); - const layerAssets = DesktopAssets.layer.pipe( - Layer.provide(Layer.merge(layerFileSystem, layerEnvironment)), - ); - const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(layerAssets)); - - const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); - - assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); - assert.equal(error.fileName, fileName); - assert.equal(error.candidatePath, candidatePath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, - ); - assert.notInclude(error.message, "private filesystem diagnostic"); - }), - ); -}); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index f70afbbad1ef..1258d29aaef3 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,12 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. import * as NodeHttp from "node:http"; -import * as NodePath from "node:path"; -import * as PlatformNodePath from "@effect/platform-node/NodePath"; +import * as NodePath from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; @@ -61,15 +59,11 @@ const layerDesktopClerk = ( showItemInFolder: () => Effect.void, }, listeners = new Map void>(), - isPackaged = false, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, appDataDirectory: "/tmp/app-data", - isPackaged, - dirname: platform === "win32" ? String.raw`C:\T3 Code\dist-electron` : "/tmp/dist-electron", - path: platform === "win32" ? NodePath.win32 : NodePath.posix, platform, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -101,7 +95,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - PlatformNodePath.layerPosix, + NodePath.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -118,101 +112,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it.effect.each([ - { platform: "win32", isPackaged: false, isDevelopment: false, registers: true }, - { platform: "win32", isPackaged: false, isDevelopment: true, registers: false }, - { platform: "win32", isPackaged: true, isDevelopment: false, registers: false }, - { platform: "darwin", isPackaged: false, isDevelopment: false, registers: false }, - { platform: "linux", isPackaged: false, isDevelopment: false, registers: false }, - ] as const)( - "registers an entry point only for unpackaged non-development Windows ($platform, packaged=$isPackaged, development=$isDevelopment)", - ({ platform, isPackaged, isDevelopment, registers }) => { - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("bridge"); - return { cleanup: vi.fn() }; - }); - const setAsDefaultProtocolClient = vi.fn(() => - Effect.sync(() => { - events.push("register"); - return true; - }), - ); - const electronApp = { - on: () => Effect.void, - setAsDefaultProtocolClient, - } as unknown as ElectronApp.ElectronApp["Service"]; - - return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - yield* clerk.configure; - - assert.deepEqual( - setAsDefaultProtocolClient.mock.calls, - registers - ? [["t3code", process.execPath, [String.raw`C:\T3 Code\dist-electron\main.cjs`]]] - : [], - ); - assert.deepEqual(events, registers ? ["bridge", "register"] : ["bridge"]); - }).pipe( - Effect.scoped, - Effect.provide( - layerDesktopClerk( - isDevelopment, - [], - platform, - undefined, - undefined, - undefined, - isPackaged, - ), - ), - Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService( - ElectronWindow.ElectronWindow, - {} as ElectronWindow.ElectronWindow["Service"], - ), - Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), - Effect.provideService(HostProcess.Arguments, ["electron"]), - ); - }, - ); - - it.effect("acquires and releases the SDK bridge with the layer", () => { - const cleanup = vi.fn(); - const events: string[] = []; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("createClerkBridge"); - return { cleanup, isPrimaryInstance: true }; - }); - - return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(layerDesktopClerk(true, events))); - - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - manageSingleInstanceLock: false, - passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, - }, - ], - ]); - assert.equal(cleanup.mock.calls.length, 1); - assert.deepEqual(events, [ - "setPath:userData:/tmp/app-data/t3code-dev", - "requestSingleInstanceLock", - "on:second-instance", - "createClerkBridge", - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); - }); - it.each([ { name: "packaged macOS", @@ -265,54 +164,6 @@ describe("DesktopClerk", () => { }, ); - it.effect("preserves bridge initialization failures", () => { - const cause = new Error("bridge initialization failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const error = yield* Effect.scoped(Layer.build(layerDesktopClerk())).pipe(Effect.flip); - - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, true); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', - ); - }); - }); - - it.effect("preserves bridge cleanup failures", () => { - const cause = new Error("bridge cleanup failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ - cleanup: () => { - throw cause; - }, - }); - - return Effect.gen(function* () { - const exit = yield* Effect.exit(Effect.scoped(Layer.build(layerDesktopClerk(false)))); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', - ); - } - }); - }); - it.effect.each(["before configure", "after configure"] as const)( "reveals the running window when a second instance launches %s", (entry) => { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index f4de0b4ebaf5..505b48194cf7 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -151,20 +151,6 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - // Clerk's bare scheme registration omits the app entry on unpackaged Windows. - // Development callbacks also need dev server environment this command cannot restore. - if ( - !environment.isPackaged && - !environment.isDevelopment && - environment.platform === "win32" - ) { - yield* electronApp.setAsDefaultProtocolClient( - ElectronProtocol.getDesktopScheme(environment.isDevelopment), - process.execPath, - [environment.path.join(environment.dirname, "main.cjs")], - ); - } - const startProviderAuthHandoff = (value: string | undefined) => { if (!value) return false; const request = readCodexAuthHandoff(value, environment.isDevelopment); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 7b44fab01855..c9a79a81d3b5 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -151,22 +151,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("does not read a large binary another t3 links to", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped(); - const binary = path.join(home, "native-t3"); - yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); - yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); - yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); - const command = yield* commandIn({ home }); - expect((yield* command.state).installedPath).toBeNull(); - yield* command.uninstall; - expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); - }).pipe(Effect.scoped), - ); - it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -237,15 +221,4 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(registry.path).toBe(userPath); }).pipe(Effect.scoped), ); - - it.effect("offers nothing for a development build", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const command = yield* commandIn({ - home: yield* fs.makeTempDirectoryScoped(), - isPackaged: false, - }); - expect((yield* command.state).supported).toBe(false); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 92f42dd2b247..e043b4d16d8d 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -64,25 +64,6 @@ const fakeAppImage = (canMount = true) => { }; describe("renderCliShim", () => { - it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { - const { root, image, shim } = fakeAppImage(); - const result = run(shim, ["browser", "setup", "a b"]); - expect(result.status).toBe(3); - expect(result.stdout.split("\n")).toEqual([ - `node=1 cli=${shim} home=${root}/home`, - `${image}/resources/app.asar/apps/server/dist/bin.mjs`, - "browser", - "setup", - "a b", - "", - ]); - NodeChildProcess.spawnSync("sh", [ - "-c", - `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, - ]); - expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); - }); - it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); @@ -124,18 +105,6 @@ describe("renderCliShim", () => { ]); }); - it("tells a person whose app moved to reopen it", () => { - const root = tempRoot(); - const shim = writeShim(root, { - kind: "direct", - executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), - entry: "/bin.mjs", - }); - const result = run(shim, ["--version"]); - expect(result.status).toBe(127); - expect(result.stderr).toContain("Open the app once to update this command."); - }); - it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; const script = renderCliShim({ @@ -155,14 +124,4 @@ describe("renderCliShim", () => { expect(bare).toEqual([]); expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); }); - - it("switches the Windows console to UTF-8 only when a path needs it", () => { - const ascii = renderCliShim({ - target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, - shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", - t3Home: "C:\\Users\\me\\.t3", - }); - expect(ascii).not.toContain("chcp"); - expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); - }); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index c0dc12e634fa..0b446f8ada30 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -1,80 +1,21 @@ -// @effect-diagnostics nodeBuiltinImport:off - The recovery fixtures use Chromium OSCrypt ciphertext. -import * as NodeCrypto from "node:crypto"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; -import { beforeEach, vi } from "vite-plus/test"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import { LinuxBrowserSecretPath } from "../preview/BrowserImport/LinuxBrowserSecret.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopSafeStorageIdentity from "./DesktopSafeStorageIdentity.ts"; - -const { getBetaPassword } = vi.hoisted(() => ({ - getBetaPassword: vi.fn<() => string | null>(), -})); - -vi.mock("@napi-rs/keyring", () => ({ - Entry: class { - readonly getPassword; - constructor(service: string, account: string) { - this.getPassword = () => - service === "T3 Code (Beta) Safe Storage" && account === "T3 Code (Beta)" - ? getBetaPassword() - : null; - } - }, -})); - -beforeEach(() => { - getBetaPassword.mockReset().mockReturnValue(null); -}); - -const savedCatalog = JSON.stringify({ - schemaVersion: 1, - targets: [ - { _tag: "RelayConnectionTarget", environmentId: "relay-environment", label: "Saved relay" }, - ], - profiles: [], - credentials: [], - remoteDpopTokens: [], - disabledEnvironmentIds: [], -}); - -const encryptBetaCatalog = (platform: NodeJS.Platform, catalog = savedCatalog) => { - const key = NodeCrypto.pbkdf2Sync( - "beta-secret", - "saltysalt", - platform === "darwin" ? 1003 : 1, - 16, - "sha1", - ); - const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, " ")); - return Buffer.concat([ - Buffer.from(platform === "darwin" ? "v10" : "v11"), - cipher.update(catalog, "utf8"), - cipher.final(), - ]); -}; const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); @@ -86,11 +27,7 @@ const encodeLegacySavedEnvironments = Schema.encodeEffect( Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }), ), ); -function layerSafeStorageFor( - available: boolean, - failDecrypt: Ref.Ref | null = null, - currentSecret?: string, -) { +function layerSafeStorageFor(available: boolean, failDecrypt: Ref.Ref | null = null) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { isEncryptionAvailable: Effect.succeed(available), encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), @@ -101,23 +38,6 @@ function layerSafeStorageFor( !decoded.startsWith("encrypted:") || (failDecrypt !== null && (yield* Ref.get(failDecrypt))) ) { - if (currentSecret !== undefined) { - return yield* Effect.try({ - try: () => { - const key = NodeCrypto.pbkdf2Sync(currentSecret, "saltysalt", 1003, 16, "sha1"); - const decipher = NodeCrypto.createDecipheriv( - "aes-128-cbc", - key, - Buffer.alloc(16, " "), - ); - return Buffer.concat([ - decipher.update(value.subarray(3)), - decipher.final(), - ]).toString("utf8"); - }, - catch: (cause) => new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause }), - }); - } return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ cause: new Error("invalid encrypted catalog"), }); @@ -129,52 +49,16 @@ function layerSafeStorageFor( } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } -const layerLinuxKeyAccess = (lookup: Effect.Effect) => - Layer.merge( - Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const secret = - command._tag === "StandardCommand" && command.args[0] === "T3 Code (Beta)" - ? yield* lookup - : null; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(secret === null ? 2 : 0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText(Stream.make(secret ?? "")), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ), - ); - -interface RecoveryOptions { - readonly platform?: NodeJS.Platform; - readonly linuxKeyLookup?: Effect.Effect; - readonly currentSecret?: string | undefined; -} - function layerFor( baseDir: string, encryptionAvailable = true, failDecrypt: Ref.Ref | null = null, fileSystemLayer: Layer.Layer = NodeServices.layer, - recovery: RecoveryOptions = {}, ) { const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, - platform: recovery.platform ?? "darwin", + platform: "darwin", processArch: "arm64", appVersion: "1.2.3", appPath: "/repo", @@ -186,18 +70,10 @@ function layerFor( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), ), ); - const layerSafeStorage = layerSafeStorageFor( - encryptionAvailable, - failDecrypt, - recovery.currentSecret, - ); + const layerSafeStorage = layerSafeStorageFor(encryptionAvailable, failDecrypt); const layerDependencies = Layer.mergeAll( layerEnvironment, layerSafeStorage, - DesktopSafeStorageIdentity.layer.pipe( - Layer.provide(layerEnvironment), - Layer.provide(layerLinuxKeyAccess(recovery.linuxKeyLookup ?? Effect.succeed(null))), - ), NodeServices.layer, fileSystemLayer, ); @@ -214,16 +90,13 @@ function layerFor( const withStore = ( effect: Effect.Effect, encryptionAvailable = true, - recovery: RecoveryOptions = {}, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-connection-catalog-test-", }); - return yield* effect.pipe( - Effect.provide(layerFor(baseDir, encryptionAvailable, null, NodeServices.layer, recovery)), - ); + return yield* effect.pipe(Effect.provide(layerFor(baseDir, encryptionAvailable))); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopConnectionCatalogStore", () => { @@ -393,144 +266,6 @@ describe("DesktopConnectionCatalogStore", () => { ), ); - it.effect("surfaces catalog filesystem failures instead of treating them as missing", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const baseFileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-connection-catalog-test-", - }); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: path.join(baseDir, "userdata", "connection-catalog.json"), - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide(layerFor(baseDir, true, null, layerFileSystem)), - ); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, - ); - assert.equal(error.catalogPath, path.join(baseDir, "userdata", "connection-catalog.json")); - assert.strictEqual(error.cause, permissionError); - assert.equal( - error.message, - `Failed to read the desktop connection catalog at ${path.join(baseDir, "userdata", "connection-catalog.json")}.`, - ); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports the failed catalog write operation and path", () => - Effect.gen(function* () { - const baseFileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-connection-catalog-test-", - }); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "makeDirectory", - pathOrDescriptor: path.join(baseDir, "userdata"), - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - makeDirectory: () => Effect.fail(permissionError), - }), - ); - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide(layerFor(baseDir, true, null, layerFileSystem)), - ); - - const error = yield* store.set("{}").pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreWriteError, - ); - assert.equal(error.operation, "create-directory"); - assert.equal(error.path, path.join(baseDir, "userdata")); - assert.strictEqual(error.cause, permissionError); - assert.equal( - error.message, - `Desktop connection catalog write failed during create-directory at ${path.join(baseDir, "userdata")}.`, - ); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports the legacy migration stage", () => - withStore( - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreMigrationError, - ); - assert.equal(error.operation, "read-legacy-registry"); - assert.equal(error.catalogPath, path.join(environment.stateDir, "connection-catalog.json")); - assert.instanceOf( - error.cause, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - const registryError = - error.cause as DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError; - assert.exists(registryError.cause); - assert.equal( - error.message, - `Legacy desktop saved-environment migration failed during read-legacy-registry into ${path.join(environment.stateDir, "connection-catalog.json")}.`, - ); - assert.notEqual(error.message, registryError.message); - }), - ), - ); - - it.effect("reports invalid encrypted catalog data without exposing it", () => - withStore( - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, '{"version":1,"encryptedCatalog":"%%%"}\n'); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, - ); - assert.equal(error.resource, "encryptedCatalog"); - assert.equal(error.catalogPath, catalogPath); - assert.exists(error.cause); - assert.equal( - error.message, - `Failed to decode encryptedCatalog for the desktop connection catalog at ${catalogPath}.`, - ); - assert.notInclude(error.message, "%%%"); - }), - ), - ); - it.effect("surfaces a catalog that can no longer be decrypted without deleting it", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -566,133 +301,4 @@ describe("DesktopConnectionCatalogStore", () => { assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - - const writeBetaCatalog = ( - platform: NodeJS.Platform, - encryptedCatalog = encryptBetaCatalog(platform), - ) => - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - const contents = `${JSON.stringify({ - version: 1, - encryptedCatalog: Base64.encode(encryptedCatalog), - })}\n`; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(catalogPath, contents); - return { catalogPath, contents }; - }); - - it.effect.each([ - { platform: "darwin" as const, currentSecret: undefined, label: "macOS" }, - { platform: "linux" as const, currentSecret: undefined, label: "Linux" }, - { - platform: "darwin" as const, - currentSecret: "wrong-key-494", - label: "macOS with valid padding under the wrong current key", - }, - ])( - "recovers a Beta catalog on $label and reopens it without the Beta key", - ({ platform, currentSecret }) => - withStore( - Effect.gen(function* () { - getBetaPassword.mockReturnValue("beta-secret"); - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath, contents } = yield* writeBetaCatalog(platform); - - assert.deepStrictEqual(yield* store.get, Option.some(savedCatalog)); - assert.notEqual(yield* fileSystem.readFileString(catalogPath), contents); - - getBetaPassword.mockReturnValue(null); - const reopened = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( - Effect.provide( - layerFor(environment.homeDirectory, true, null, NodeServices.layer, { platform }), - ), - ); - assert.deepStrictEqual(yield* reopened.get, Option.some(savedCatalog)); - }), - true, - { platform, currentSecret, linuxKeyLookup: Effect.succeed("beta-secret") }, - ), - ); - - it.effect.each([ - // This wrong key has valid CBC padding, so decryption alone cannot validate recovery. - { platform: "darwin" as const, secret: "wrong-key-494", label: "a wrong Beta key" }, - { platform: "linux" as const, secret: null, label: "a missing Beta key" }, - { - platform: "darwin" as const, - secret: "beta-secret", - label: "unencrypted input", - encryptedCatalog: Buffer.from(savedCatalog), - }, - { - platform: "linux" as const, - secret: "beta-secret", - label: "an invalid catalog", - encryptedCatalog: encryptBetaCatalog("linux", '{"schemaVersion":1,"targets":null}'), - }, - ])("leaves the catalog bytes intact for $label", ({ platform, secret, encryptedCatalog }) => - withStore( - Effect.gen(function* () { - getBetaPassword.mockReturnValue(secret); - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath, contents } = yield* writeBetaCatalog(platform, encryptedCatalog); - - const error = yield* store.get.pipe(Effect.flip); - assert.instanceOf( - error, - DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreProtectionError, - ); - assert.equal(error.operation, "decrypt-catalog"); - assert.equal(yield* fileSystem.readFileString(catalogPath), contents); - }), - true, - { platform, linuxKeyLookup: Effect.succeed(secret) }, - ), - ); - - it.effect.each(["save", "clear"] as const)( - "preserves a concurrent %s while Beta key recovery is waiting", - (operation) => - Effect.gen(function* () { - const lookupStarted = yield* Deferred.make(); - const lookupReleased = yield* Deferred.make(); - const linuxKeyLookup = Deferred.succeed(lookupStarted, undefined).pipe( - Effect.andThen(Deferred.await(lookupReleased)), - Effect.as("beta-secret"), - ); - const newer = savedCatalog.replace("Saved relay", "Updated relay"); - - yield* withStore( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const { catalogPath } = yield* writeBetaCatalog("linux"); - const recovery = yield* store.get.pipe(Effect.forkChild); - yield* Deferred.await(lookupStarted); - if (operation === "save") { - assert.isTrue(yield* store.set(newer)); - } else { - yield* store.clear; - } - yield* Deferred.succeed(lookupReleased, undefined); - yield* Fiber.join(recovery); - - assert.deepStrictEqual( - yield* store.get, - operation === "save" ? Option.some(newer) : Option.none(), - ); - assert.equal(yield* fileSystem.exists(catalogPath), operation === "save"); - }), - true, - { platform: "linux", linuxKeyLookup }, - ); - }), - ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 35800e67865b..a9020c36d1c8 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -21,12 +21,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import * as DesktopSavedEnvironments from "../settings/DesktopSavedEnvironments.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopSafeStorageIdentity from "./DesktopSafeStorageIdentity.ts"; const EncryptedConnectionCatalogDocument = Schema.Struct({ version: Schema.Literal(1), @@ -47,8 +45,6 @@ const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); -const decodeCatalogJsonOption = Schema.decodeOption(RuntimeConnectionCatalogDocumentJson); -const decodeNativeCatalogJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-temporary-file-name", @@ -386,11 +382,9 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const safeStorageIdentity = yield* DesktopSafeStorageIdentity.DesktopSafeStorageIdentity; const crypto = yield* Crypto.Crypto; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); - const writeLock = yield* Semaphore.make(1); const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( Effect.mapError( (cause) => @@ -477,40 +471,6 @@ export const make = Effect.gen(function* () { return Option.some(encoded); }); - const recoverPreviousIdentityCatalog = Effect.fn( - "desktop.connectionCatalogStore.recoverPreviousIdentityCatalog", - )(function* ( - document: EncryptedConnectionCatalogDocument, - encryptedCatalog: Uint8Array, - cause: ElectronSafeStorage.ElectronSafeStorageDecryptError | Schema.SchemaError, - ) { - const recovered = Option.filter( - yield* safeStorageIdentity.decryptWithPreviousName(encryptedCatalog), - (catalog) => Option.isSome(decodeCatalogJsonOption(catalog)), - ); - if (Option.isNone(recovered)) { - return yield* new DesktopConnectionCatalogStoreProtectionError({ - operation: "decrypt-catalog", - catalogPath, - cause, - }); - } - // Reading the previous key can wait on a Keychain prompt, so a newer catalog - // may have been saved since this one was read. - yield* Effect.gen(function* () { - const current = yield* readDocument(fileSystem, catalogPath); - if (Option.isSome(current) && current.value.encryptedCatalog === document.encryptedCatalog) { - yield* writeCatalog(recovered.value); - } - }).pipe( - writeLock.withPermits(1), - Effect.catch((error) => - Effect.logWarning("Could not re-encrypt the connection catalog.", { catalogPath, error }), - ), - ); - return recovered.value; - }); - return DesktopConnectionCatalogStore.of({ get: Effect.gen(function* () { const document = yield* readDocument(fileSystem, catalogPath); @@ -523,14 +483,13 @@ export const make = Effect.gen(function* () { const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( Effect.flatMap((encryptedCatalog) => safeStorage.decryptString(encryptedCatalog).pipe( - // CBC can decrypt with the wrong key without failing its padding check. - Effect.tap((catalog) => - environment.platform === "darwin" || environment.platform === "linux" - ? decodeNativeCatalogJson(catalog) - : Effect.void, - ), - Effect.catch((cause) => - recoverPreviousIdentityCatalog(document.value, encryptedCatalog, cause), + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), ), ), ), @@ -541,11 +500,10 @@ export const make = Effect.gen(function* () { if (!(yield* encryptionAvailable)) { return false; } - yield* writeCatalog(catalog).pipe(writeLock.withPermits(1)); + yield* writeCatalog(catalog); return true; }), clear: fileSystem.remove(catalogPath, { force: true }).pipe( - writeLock.withPermits(1), Effect.catch((error) => Effect.logWarning("Could not clear the desktop connection catalog.", { catalogPath, diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index 6f72aa1f509d..6d40bd128b77 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -4,7 +4,6 @@ import { assert, describe, it } from "@effect/vitest"; import { restoreEarlyLinuxDeviceScaleFactor, - resolveEarlyLinuxElectronOptions, resolveEarlyLinuxPasswordStorePreference, resolveLinuxDisableFeaturesSwitch, } from "./DesktopEarlyElectronStartup.ts"; @@ -53,83 +52,11 @@ describe("DesktopEarlyElectronStartup", () => { assert.equal(preference, "auto"); }); - it("preserves absolute root paths when resolving early settings", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { T3CODE_HOME: "/" }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/userdata/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "kwallet6" }); - }, - }); - - assert.equal(preference, "kwallet6"); - }); - - it("resolves the early linux Electron switches", () => { - const options = resolveEarlyLinuxElectronOptions({ - env: { - T3CODE_HOME: "/home/user/.t3-test", - XDG_CURRENT_DESKTOP: "niri", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "auto" }); - }, - }); - - assert.deepEqual(options, { - isDevelopment: true, - linuxWmClass: "t3code-dev", - linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop", - passwordStore: "gnome-libsecret", - }); - }); - - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "kwallet" }); - }, - }); - - assert.equal(preference, "kwallet"); - }); - - it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { - const preference = resolveEarlyLinuxPasswordStorePreference({ - env: { - T3CODE_HOME: " ", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - }, - homeDirectory: "/home/user", - joinPath, - readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); - return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); - }, - }); - - assert.equal(preference, "gnome-libsecret"); - }); - describe("resolveLinuxDisableFeaturesSwitch", () => { const hyprland = { XDG_CURRENT_DESKTOP: "Hyprland", XDG_SESSION_TYPE: "wayland" }; const releaseCommits: Record = { "0.48.0": "5ee35f914f921e5696030698e74fb5566a804768", "0.48.1": "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - "0.49.0": "9958d297641b5c84dcff93f9039d80a5ad37ab00", - "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213", "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc", }; const hyprctlVersion = @@ -151,18 +78,15 @@ describe("DesktopEarlyElectronStartup", () => { readHyprctlVersionJson: hyprctlVersion("0.48.1"), }; - it.each(["0.48.0", "0.48.1", "0.49.0", "0.50.0", "0.50.1"])( - "disables the color manager on Hyprland %s", - (version) => { - assert.equal( - resolveLinuxDisableFeaturesSwitch({ - ...brokenHyprland, - readHyprctlVersionJson: hyprctlVersion(version), - }), - "WaylandWpColorManagerV1", - ); - }, - ); + it.each(["0.48.0", "0.50.1"])("disables the color manager on Hyprland %s", (version) => { + assert.equal( + resolveLinuxDisableFeaturesSwitch({ + ...brokenHyprland, + readHyprctlVersionJson: hyprctlVersion(version), + }), + "WaylandWpColorManagerV1", + ); + }); it.each([ { label: "case-insensitive desktop tokens", env: { XDG_CURRENT_DESKTOP: "foo:HYPRLAND" } }, @@ -177,70 +101,6 @@ describe("DesktopEarlyElectronStartup", () => { ); }); - it("keeps existing disabled features", () => { - assert.equal( - resolveLinuxDisableFeaturesSwitch({ - ...brokenHyprland, - disableFeatures: "Vulkan,WaylandWpColorManagerV1Extra", - }), - "Vulkan,WaylandWpColorManagerV1Extra,WaylandWpColorManagerV1", - ); - }); - - it.each([ - { label: "Hyprland 0.47.2", readHyprctlVersionJson: hyprctlVersion("0.47.2") }, - { label: "Hyprland 0.51.0", readHyprctlVersionJson: hyprctlVersion("0.51.0") }, - { label: "Hyprland 0.56.2", readHyprctlVersionJson: hyprctlVersion("0.56.2") }, - { label: "an unrecognized version", readHyprctlVersionJson: hyprctlVersion("1.0.0") }, - { - label: "a malformed version", - readHyprctlVersionJson: hyprctlVersion("0.48.not-a-version"), - }, - { label: "a two-part version", readHyprctlVersionJson: hyprctlVersion("0.48") }, - { label: "a prerelease version", readHyprctlVersionJson: hyprctlVersion("0.49.0-rc1") }, - { - label: "a development build after an affected release", - readHyprctlVersionJson: hyprctlVersion("0.50.1", { tag: "v0.50.1-87-g4e8875b5" }), - }, - { - label: "a later Nix build reporting an affected release tag", - readHyprctlVersionJson: hyprctlVersion("0.50.1", { - commit: "4e8875b5e9700c81ca4e169dc7b85bb5b3c8cb7a", - }), - }, - { - label: "an affected version with another release's commit", - readHyprctlVersionJson: hyprctlVersion("0.48.0", { commit: releaseCommits["0.48.1"] }), - }, - { label: "an unknown patch release", readHyprctlVersionJson: hyprctlVersion("0.50.2") }, - { - label: "missing commit metadata", - readHyprctlVersionJson: () => - JSON.stringify({ version: "0.48.1", tag: "v0.48.1", dirty: false }), - }, - { - label: "a build without git metadata", - readHyprctlVersionJson: hyprctlVersion("0.48.1", { tag: "" }), - }, - { - label: "a dirty build", - readHyprctlVersionJson: hyprctlVersion("0.48.1", { dirty: true }), - }, - { - label: "output without build metadata", - readHyprctlVersionJson: () => JSON.stringify({ version: "0.48.1" }), - }, - { label: "unparseable hyprctl output", readHyprctlVersionJson: () => "ok" }, - { - label: "a missing hyprctl", - readHyprctlVersionJson: () => { - throw new Error("spawnSync hyprctl ENOENT"); - }, - }, - ])("preserves color management for $label", (overrides) => { - assert.isNull(resolveLinuxDisableFeaturesSwitch({ ...brokenHyprland, ...overrides })); - }); - it.each([ { label: "a stale Hyprland signature in KDE", diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index fb6c5a8b1afd..200745f11136 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -3,7 +3,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; @@ -39,86 +38,6 @@ const makeEnvironment = ( ) => DesktopEnvironment.DesktopEnvironment.pipe(Effect.provide(layerEnvironment(overrides, env))); describe("DesktopEnvironment", () => { - it.effect("derives state paths and development identity inside Effect", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_HOME: " /tmp/t3 ", - T3CODE_COMMIT_HASH: " 0123456789abcdef ", - T3CODE_PORT: "4949", - VITE_DEV_SERVER_URL: "http://localhost:5173", - T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", - T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", - T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ", - T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ", - T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", - T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", - T3CODE_OTLP_PROTOCOL: "http/protobuf", - }, - ); - - assert.equal(environment.isDevelopment, true); - assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); - assert.equal(environment.baseDir, "/tmp/t3"); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); - assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); - assert.equal( - environment.savedEnvironmentRegistryPath, - "/tmp/t3/userdata/saved-environments.json", - ); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); - assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); - assert.equal(environment.rootDir, "/repo"); - assert.equal(environment.appRoot, "/repo"); - assert.equal(environment.serverRoot, "/repo"); - assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); - assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.Development.desktop"); - assert.deepEqual( - Option.map(environment.devServerUrl, (url) => url.href), - Option.some("http://localhost:5173/"), - ); - assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); - assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); - assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); - assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); - assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics")); - assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs")); - assert.equal(environment.otlpExportIntervalMs, 2500); - assert.deepEqual( - environment.otlpHeaders, - Option.some({ - authorization: "Basic abc==", - "x-tenant": "t3", - }), - ); - assert.equal(environment.otlpProtocol, "http/protobuf"); - }), - ); - - it.effect("stores production state under userdata in an explicit home", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_HOME: "/tmp/t3", - }, - ); - - assert.equal(environment.isDevelopment, false); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); - assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.otlpProtocol, "http/json"); - }), - ); - it.effect("uses the packaged Windows server sidecar as the backend root", () => Effect.gen(function* () { const environment = yield* makeEnvironment({ @@ -141,19 +60,6 @@ describe("DesktopEnvironment", () => { }), ); - it.effect("uses the stable desktop entry as the packaged Linux portal identity", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment({ - platform: "linux", - isPackaged: true, - appPath: "/tmp/.mount_t3code/resources/app.asar", - resourcesPath: "/tmp/.mount_t3code/resources", - }); - - assert.equal(environment.linuxDesktopEntryName, "com.t3tools.T3Code.desktop"); - }), - ); - it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( @@ -166,38 +72,4 @@ describe("DesktopEnvironment", () => { assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); }), ); - - it.effect("uses a configured app user model id override", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment( - {}, - { - T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", - VITE_DEV_SERVER_URL: "http://localhost:5173", - }, - ); - - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); - }), - ); - - it.effect("resolves picker defaults without nullish sentinels", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment(); - - assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: " " }), - Option.none(), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~" }), - Option.some("/Users/alice"), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), - Option.some("/Users/alice/project"), - ); - }), - ); }); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index e3509448515c..ecf08255feb3 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -15,10 +15,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; -import { - isNightlyDesktopVersion, - resolveDefaultDesktopUpdateChannel, -} from "../updates/updateChannels.ts"; +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; import type { OtlpProtocol } from "@t3tools/shared/observability"; export interface MakeDesktopEnvironmentInput { @@ -113,13 +110,6 @@ export function resolveDesktopAppBranding(input: { }; } -// Keep Chromium's encryption identity on the names shipped before branding changed. -export function resolveDesktopSafeStorageName(appVersion: string): string { - return resolveDefaultDesktopUpdateChannel(appVersion) === "nightly" - ? "T3 Code (Nightly)" - : "T3 Code (Alpha)"; -} - function normalizeDesktopArch(arch: string): DesktopRuntimeArch { if (arch === "arm64") return "arm64"; if (arch === "x64") return "x64"; diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 43fb27cac6cd..f9f38dea3dd4 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -1,13 +1,6 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off - A real Node process verifies OS signal ownership outside Effect's process runtime. -import * as NodeChildProcess from "node:child_process"; -import * as NodeReadline from "node:readline"; -import * as NodeURL from "node:url"; - import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -110,67 +103,6 @@ function layerDesktopWindow( } describe("DesktopLifecycle", () => { - it.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "leaves SIGTERM to the lifecycle until its graceful shutdown completes", - async ({ onTestFinished }) => { - const runner = new URL("./DesktopRuntime.ts", import.meta.url).href; - const child = NodeChildProcess.spawn( - process.execPath, - [ - "--experimental-strip-types", - "--input-type=module", - "-e", - ` - import * as Effect from 'effect/Effect'; - import * as Deferred from 'effect/Deferred'; - import { runMain } from ${JSON.stringify(runner)}; - process.stdin.resume(); - let released = false; - runMain(Effect.scoped(Effect.gen(function* () { - const done = yield* Deferred.make(); - yield* Effect.addFinalizer(() => Effect.sync(() => console.log(released ? 'CLEANED' : 'PREMATURE'))); - process.once('SIGTERM', () => { - console.log('STOPPING'); - process.stdin.once('data', () => { - released = true; - void Effect.runPromise(Deferred.succeed(done, undefined)).then(() => process.stdin.destroy()); - }); - }); - console.log('READY'); - yield* Deferred.await(done); - }))); - `, - ], - { - cwd: NodeURL.fileURLToPath(new URL("../../", import.meta.url)), - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const exited = new Promise((resolve, reject) => { - child.once("exit", (code, signal) => resolve([code, signal])); - child.once("error", reject); - }); - const lines: string[] = []; - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - onTestFinished(async () => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - await exited; - } - }); - for await (const line of NodeReadline.createInterface({ input: child.stdout })) { - lines.push(line); - if (line === "READY") child.kill("SIGTERM"); - if (line === "STOPPING") child.stdin.write("finish"); - } - assert.deepEqual(await exited, [0, null], stderr); - assert.deepEqual(lines, ["READY", "STOPPING", "CLEANED"]); - }, - ); - it.effect.each(["darwin", "win32", "linux"] satisfies ReadonlyArray)( "lets the updater's quit event proceed on %s", (platform) => { @@ -226,18 +158,12 @@ describe("DesktopLifecycle", () => { }, ); - it.effect("destroys windows and retains signal ownership until backend shutdown completes", () => + it.effect("destroys windows before waiting for backend shutdown", () => Effect.gen(function* () { const appListeners = new Map void>(); const shutdownRequested = yield* Deferred.make(); const allowShutdown = yield* Deferred.make(); const quitRequested = yield* Deferred.make(); - const backendStopping = yield* Deferred.make(); - const finishBackendShutdown = yield* Deferred.make(); - const originalSignals = { - SIGINT: process.listeners("SIGINT"), - SIGTERM: process.listeners("SIGTERM"), - }; const events: string[] = []; const quit = Effect.sync(() => { @@ -278,47 +204,20 @@ describe("DesktopLifecycle", () => { yield* Effect.scoped( Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const shutdown = yield* DesktopShutdown.DesktopShutdown; - const program = yield* Effect.scoped( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Deferred.succeed(backendStopping, undefined).pipe( - Effect.andThen(Deferred.await(finishBackendShutdown)), - Effect.andThen(shutdown.markComplete), - ), - ); - yield* lifecycle.register; - const event = { preventDefault: () => undefined } as Electron.Event; - appListeners.get("before-quit")?.(event); - yield* shutdown.awaitRequest; - }), - ).pipe(Effect.forkScoped); - - yield* Deferred.await(backendStopping); - yield* Effect.addFinalizer(() => - Deferred.succeed(finishBackendShutdown, undefined).pipe(Effect.asVoid), - ); + yield* lifecycle.register; - for (const signal of ["SIGINT", "SIGTERM"] as const) { - const listeners = process - .listeners(signal) - .filter((listener) => !originalSignals[signal].includes(listener)); - assert.lengthOf(listeners, 1, `${signal} must remain owned during backend cleanup`); - listeners[0]?.(signal); - } - assert.deepEqual(events, ["flush", "destroy", "request"]); - assert.equal(appListeners.size, 0, "Electron listeners still follow the program scope"); - assert.isFalse(yield* shutdown.isComplete); + const event = { preventDefault: () => undefined } as Electron.Event; + appListeners.get("before-quit")?.(event); - yield* Deferred.succeed(finishBackendShutdown, undefined); - yield* Fiber.join(program); + yield* Deferred.await(shutdownRequested); + const eventsBeforeCleanup = [...events]; + yield* Deferred.succeed(allowShutdown, undefined); yield* Deferred.await(quitRequested); + + assert.deepEqual(eventsBeforeCleanup, ["flush", "destroy", "request"]); assert.deepEqual(events, ["flush", "destroy", "request", "quit"]); }), ).pipe(Effect.provide(layer)); - - assert.deepEqual(process.listeners("SIGINT"), originalSignals.SIGINT); - assert.deepEqual(process.listeners("SIGTERM"), originalSignals.SIGTERM); }), ); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a464021171f..924341781aa6 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -160,113 +160,108 @@ function quitFromSignal( } /** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const signalScope = yield* Scope.Scope; - return DesktopLifecycle.of({ - relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - yield* logLifecycleInfo("desktop relaunch requested", { reason }); - yield* Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); - if (environment.isDevelopment) { - yield* electronApp.exit(75); - return; - } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - yield* electronApp.exit(0); - }).pipe( - Effect.catchCause((cause) => { - const error = new DesktopLifecycleRelaunchError({ reason, cause }); - return logLifecycleError(error.message, { error }); - }), - Effect.forkDetach, - Effect.asVoid, - ); - }), - register: Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const electronApp = yield* ElectronApp.ElectronApp; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const context = yield* Effect.context(); - const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; - let updaterQuitAllowed = false; - yield* electronTheme.onUpdated(() => { - void runEffect( - desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), - ); +export const make = DesktopLifecycle.of({ + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), }); - yield* electronApp.onBeforeQuitForUpdate(() => { - // Electron's updater owns the remaining quit/install/relaunch sequence. - // Cancelling the following app "before-quit" event breaks that sequence, - // most visibly on macOS where the native updater performs the relaunch. - updaterQuitAllowed = true; - // This event is synchronous and the updater's quit proceeds as soon as - // the listener returns, so a forked destroyAll would race the quit - // and windows could still be open when the process exits (visible on - // macOS). Destroy them inline. - Effect.runSyncWith(context)( - electronWindow.destroyAll.pipe( - Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), - Effect.catchCause((cause) => - logLifecycleError("failed to destroy windows before updater quit", { cause }), - ), - Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), + Effect.forkDetach, + Effect.asVoid, + ); + }), + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = Effect.runPromiseWith(context); + let quitAllowed = false; + let updaterQuitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); + }); + yield* electronApp.onBeforeQuitForUpdate(() => { + // Electron's updater owns the remaining quit/install/relaunch sequence. + // Cancelling the following app "before-quit" event breaks that sequence, + // most visibly on macOS where the native updater performs the relaunch. + updaterQuitAllowed = true; + // This event is synchronous and the updater's quit proceeds as soon as + // the listener returns, so a forked destroyAll would race the quit + // and windows could still be open when the process exits (visible on + // macOS). Destroy them inline. + Effect.runSyncWith(context)( + electronWindow.destroyAll.pipe( + Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), + Effect.catchCause((cause) => + logLifecycleError("failed to destroy windows before updater quit", { cause }), ), - ); - }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed || updaterQuitAllowed, - () => { - quitAllowed = true; - }, - ); - }); - yield* electronApp.on("activate", () => { - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - if (yield* Ref.get(state.quitting)) return; - yield* desktopWindow.activate; - }).pipe(Effect.withSpan("desktop.lifecycle.activate")), - ); + Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + ), + ); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed || updaterQuitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (yield* Ref.get(state.quitting)) return; + yield* desktopWindow.activate; + }).pipe(Effect.withSpan("desktop.lifecycle.activate")), + ); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), + ); + }); + + if (environment.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); }); - yield* electronApp.on("window-all-closed", () => { - void runEffect( - Effect.gen(function* () { - const app = yield* ElectronApp.ElectronApp; - const state = yield* DesktopState.DesktopState; - if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* app.quit; - } - }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), - ); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); }); - - if (environment.platform !== "win32") { - // The layer outlives program finalizers, so repeated signals stay owned - // while backends stop. Electron listeners still follow the program scope. - yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); - }).pipe(Scope.provide(signalScope)); - yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); - }).pipe(Scope.provide(signalScope)); - } - }).pipe(Effect.withSpan("desktop.lifecycle.register")), - }); + } + }).pipe(Effect.withSpan("desktop.lifecycle.register")), }); -export const layer = Layer.effect(DesktopLifecycle, make); +export const layer = Layer.succeed(DesktopLifecycle, make); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 41f20ef984db..854f43c5623c 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -10,7 +9,6 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -22,31 +20,8 @@ interface RecordedRegistration { readonly files: Array<{ readonly path: string; readonly content: string }>; readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; readonly copies: Array<{ readonly source: string; readonly destination: string }>; - readonly binaryReads: string[]; - readonly binaryWrites: Array<{ - readonly path: string; - readonly bytes: Uint8Array; - readonly mode: number | undefined; - }>; } -const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; -const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; -const iconBytes = new Uint8Array([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); -const currentDesktopEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code (Alpha)", - execTarget: "/home/alice/Applications/T3-Code.AppImage", - scheme: "t3code", - iconPath, -}); -const registrationCommands = [ - { command: "update-desktop-database", args: ["/home/alice/.local/share/applications"] }, - { - command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], - }, -]; - const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", @@ -87,8 +62,7 @@ const layerHandler = ( readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; readonly iconSource?: string; - readonly existingIcon?: Uint8Array; - readonly iconReadError?: PlatformError.PlatformError; + readonly iconCopyError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( @@ -111,34 +85,11 @@ const layerHandler = ( }), FileSystem.layerNoop({ copyFile: (source, destination) => - Effect.sync(() => { - recorded.copies.push({ source, destination }); - }), - readFile: (path) => - Effect.gen(function* () { - recorded.binaryReads.push(path); - if (path === input.iconSource) { - if (input.iconReadError) return yield* input.iconReadError; - return new Uint8Array(iconBytes); - } - if (path === iconPath && input.existingIcon !== undefined) { - return input.existingIcon; - } - return yield* PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: path, - }); - }), - writeFile: (path, bytes, options) => - Effect.sync(() => { - recorded.binaryWrites.push({ - path, - bytes: new Uint8Array(bytes), - mode: options?.mode, - }); - }), + input.iconCopyError + ? Effect.fail(input.iconCopyError) + : Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -203,8 +154,6 @@ const emptyRecording = (): RecordedRegistration => ({ files: [], commands: [], copies: [], - binaryReads: [], - binaryWrites: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -228,52 +177,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); - it.each([ - { - execTarget: "/opt/T3(beta)/t3code.AppImage", - execLine: 'Exec="/opt/T3(beta)/t3code.AppImage" %U', - }, - { - execTarget: "/opt/t3%20code/t3code.AppImage", - execLine: "Exec=/opt/t3%%20code/t3code.AppImage %U", - }, - ])("renders the handler Exec line for $execTarget", ({ execTarget, execLine }) => { - const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ - displayName: "T3 Code", - execTarget, - scheme: "t3code", - }); - - assert.include(entry.split("\n"), execLine); - }); - - it("carries structured context on registration errors", () => { - const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ - step: "write-desktop-entry", - scheme: "t3code", - desktopEntryPath: "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - cause: new Error("boom"), - }); - assert.equal( - writeError.message, - "Failed to register the t3code:// URL handler (step: write-desktop-entry).", - ); - assert.equal( - writeError.desktopEntryPath, - "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - ); - - const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ - step: "set-default-handler", - scheme: "t3code", - exitCode: 4, - }); - assert.equal( - exitError.message, - "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", - ); - }); - it.effect( "writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { @@ -355,57 +258,42 @@ describe("DesktopLinuxUrlHandler", () => { it.effect("installs a persistent icon even when the desktop entry is already current", () => { const recorded = emptyRecording(); + const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; return Effect.gen(function* () { yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - }); - assert.deepEqual(recorded.files, []); - assert.deepEqual(recorded.copies, []); - assert.deepEqual(recorded.binaryReads, [iconSource, iconPath]); - assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); - assert.deepEqual(recorded.commands, registrationCommands); - }); - }); - - it.effect("refreshes a stale icon and skips equal bytes on the next registration", () => { - const recorded = emptyRecording(); - return Effect.gen(function* () { - yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - existingIcon: new Uint8Array([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]), - }); - assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); - yield* runRegister(recorded, { - iconSource, - existingEntry: currentDesktopEntry, - existingIcon: new Uint8Array(recorded.binaryWrites[0]!.bytes), + iconSource: "/tmp/.mount_T3/resources/icon.png", + existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, + }), }); - assert.equal(recorded.binaryWrites.length, 1); - assert.deepEqual(recorded.copies, []); assert.deepEqual(recorded.files, []); - assert.deepEqual(recorded.commands, [...registrationCommands, ...registrationCommands]); + assert.deepEqual(recorded.copies, [ + { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath }, + ]); + assert.equal(recorded.commands.at(-1)?.command, "xdg-mime"); }); }); - it.effect("still registers the handler when reading its icon fails", () => { + it.effect("still registers the handler when copying its icon fails", () => { const recorded = emptyRecording(); return Effect.gen(function* () { yield* runRegister(recorded, { - iconSource, - iconReadError: PlatformError.systemError({ + iconSource: "/tmp/.mount_T3/resources/icon.png", + iconCopyError: PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", - method: "readFile", - description: "unreadable bundled icon", + method: "copyFile", + description: "read-only icon directory", }), }); - assert.deepEqual(recorded.binaryReads, [iconSource]); - assert.deepEqual(recorded.binaryWrites, []); - assert.deepEqual(recorded.copies, []); assert.equal(recorded.files.length, 1); - assert.deepEqual(recorded.commands, registrationCommands); + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); }); }); @@ -457,26 +345,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); - - it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () => - Effect.gen(function* () { - const recorded = emptyRecording(); - const started = yield* Deferred.make(); - const registration = yield* runRegister(recorded, { - updateDesktopDatabaseStalled: true, - updateDesktopDatabaseStarted: started, - }).pipe(Effect.forkChild); - - yield* Deferred.await(started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(registration); - - assert.deepEqual( - recorded.commands.map(({ command }) => command), - ["update-desktop-database", "xdg-mime"], - ); - }), - ); }); interface ThemeIconRun { @@ -736,33 +604,4 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { ); }).pipe(Effect.scoped), ); - - it.effect("continues to xdg-mime when the icon cache refresh stalls", () => - Effect.gen(function* () { - const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3])); - yield* fixture.seedCache; - const started = yield* Deferred.make(); - const iconCacheOptions: Array> = []; - const run: ThemeIconRun = { - root: fixture.root, - commands: [], - iconCacheOptions, - iconCacheStalled: true, - iconCacheStarted: started, - }; - - const registration = yield* registerWithRealFileSystem(run).pipe(Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(registration); - - assert.deepEqual( - run.commands.map(({ command }) => command), - ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], - ); - // Without a forced kill, releasing a helper that ignores SIGTERM after - // the timeout would still wait for it to exit. - assert.equal(iconCacheOptions[0]?.forceKillAfter, "1 second"); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 83c595ea678c..e03418964e33 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -301,16 +301,12 @@ export const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { png } = yield* assets.iconPaths; if (Option.isNone(png)) return; - // Electron's copyFile extracts ASAR entries to temporary files it never removes. - const icon = yield* fileSystem.readFile(png.value); - const current = yield* fileSystem.readFile(iconPath).pipe(Effect.option); - if (Option.isSome(current) && sameBytes(current.value, icon)) return; // The AppImage mount is temporary; the chooser needs the icon after exit. yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); - yield* fileSystem.writeFile(iconPath, icon, { mode: 0o600 }); + yield* fileSystem.copyFile(png.value, iconPath); }).pipe( Effect.catch((error) => - logWarning("URL handler icon install failed", { iconPath, category: error.reason._tag }), + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), ), ); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index ef462f19a29b..f865ed312063 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -118,76 +118,6 @@ const writeObservabilitySettings = Effect.fn(function* ( }); describe("DesktopObservability", () => { - it("advances a retained output offset instead of repeatedly copying a full head chunk", () => { - const maxBufferedBytes = 1024 * 1024; - const initial = DesktopObservability.appendBoundedOutputChunk( - { - runId: "test-run", - startDetails: "pid=123", - chunks: [], - byteLength: 0, - }, - "stderr", - new Uint8Array(maxBufferedBytes), - ); - const initialBackingBuffer = initial.chunks[0]?.chunk.buffer; - - const next = DesktopObservability.appendBoundedOutputChunk(initial, "stderr", Uint8Array.of(1)); - - assert.equal(next.chunks[0]?.chunk.buffer, initialBackingBuffer); - assert.equal(next.chunks[0]?.offset, 1); - assert.equal(next.byteLength, maxBufferedBytes); - }); - - it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir); - const tracePath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop.trace.ndjson"); - }).pipe(Effect.provide(layerEnvironment)); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop-main.log"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ "desktop.test": true }); - yield* Effect.logInfo("desktop trace event"); - }).pipe( - Effect.withSpan("desktop-observability-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - const records = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)); - const record = records.find((entry) => entry.name === "desktop-observability-test"); - - assert.notEqual(record, undefined); - if (!record) { - return; - } - assert.equal(record.attributes["desktop.test"], true); - assert.equal( - record.events.some((event) => event.name === "desktop trace event"), - true, - ); - assert.isFalse(yield* fileSystem.exists(logPath)); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, layerEmptyEnv)), - ), - ); - it.effect("buffers backend child output and persists it only when a failure is reported", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -350,306 +280,6 @@ describe("DesktopObservability", () => { ), ); - it.effect("bounds the number of retained backend child output chunks", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-output-chunks-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, false); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "server-child.log"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.gen(function* () { - const factory = yield* DesktopObservability.DesktopBackendOutputLogFactory; - const outputLog = yield* factory.forInstance("primary"); - yield* outputLog.beginSession({ details: "pid=123" }); - for (let index = 0; index < 300; index += 1) { - yield* outputLog.writeOutputChunk("stderr", Uint8Array.of(index % 128)); - } - yield* outputLog.persistFailure({ details: "code=1" }); - }).pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - const lines = (yield* fileSystem.readFileString(logPath)).trimEnd().split("\n"); - assert.equal(lines.length, 258); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, layerEmptyEnv)), - ), - ); - - it.effect("exports main process log records to the configured logs endpoint", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - const tracePath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop.trace.ndjson"); - }).pipe(Effect.provide(layerEnvironment)); - - yield* Effect.scoped( - Effect.logInfo("desktop log export").pipe( - Effect.withSpan("desktop-log-export-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.include(request?.body ?? "", "desktop log export"); - assert.include(request?.body ?? "", "service.runtime"); - assert.strictEqual(request?.headers["x-scope"], "desktop"); - - const record = (yield* fileSystem.readFileString(tracePath)) - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeTraceRecordLine(line)) - .find((entry) => entry.name === "desktop-log-export-test"); - assert.notEqual(record, undefined); - assert.lengthOf(record?.events ?? [], 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - - it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - yield* writeObservabilitySettings(layerEnvironment, { - otlpLogsUrl: "https://settings.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop otel export").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.strictEqual(request?.headers["x-otel"], "desktop"); - assert.strictEqual(request?.headers["x-scope"], undefined); - assert.strictEqual(request?.headers["content-type"], "application/json"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", - }, - }), - ), - ), - ), - ); - }); - - it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop service name").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 1); - const body = requests[0]?.body ?? ""; - assert.include(body, '"stringValue":"t3code-desktop"'); - assert.include(body, "deployment.environment.name"); - assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); - assert.notInclude(body, "renamed"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", - }, - }), - ), - ), - ), - ); - }); - - it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_HEADERS: "x-scope=desktop", - }); - yield* writeObservabilitySettings(layerEnvironment, { - otlpLogsUrl: "https://settings.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop otel off").pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.lengthOf(requests, 0); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", - OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", - }, - }), - ), - ), - ), - ); - }); - - it.effect("exports kill switch warnings through the configured logger", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir, true, { - T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", - }); - - yield* Effect.scoped( - Effect.void.pipe( - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - layerCollector(requests), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), - ), - ), - ); - }); - - it.effect("reads every signal endpoint from Settings when the environment names none", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - const layerEnvironment = layerEnvironmentFor(baseDir); - yield* writeObservabilitySettings(layerEnvironment, { - otlpTracesUrl: "https://settings.example.com/v1/traces", - otlpLogsUrl: "https://settings.example.com/v1/logs", - otlpMetricsUrl: "https://settings.example.com/v1/metrics", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop log export from settings").pipe( - Effect.withSpan("desktop-settings-export-test"), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), - ), - ); - - assert.deepEqual(requests.map((request) => request.url).toSorted(), [ - "https://settings.example.com/v1/logs", - "https://settings.example.com/v1/traces", - ]); - assert.include( - requests.find((request) => request.url.endsWith("/v1/logs"))?.body ?? "", - "desktop log export from settings", - ); - assert.include( - requests.find((request) => request.url.endsWith("/v1/traces"))?.body ?? "", - "desktop-settings-export-test", - ); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - - it.effect("stays off the network when no endpoint is configured", () => { - const requests: Array = []; - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-observability-test-", - }); - - yield* Effect.scoped( - Effect.logInfo("desktop log stays local").pipe( - Effect.withSpan("desktop-offline-test"), - Effect.provide( - DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironmentFor(baseDir))), - ), - ), - ); - - assert.lengthOf(requests, 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), - ); - }); - it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 299db7019dd7..906ca1f8e4a2 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -150,7 +150,7 @@ interface BackendOutputSession { readonly byteLength: number; } -export function appendBoundedOutputChunk( +function appendBoundedOutputChunk( session: BackendOutputSession, streamName: "stdout" | "stderr", chunk: Uint8Array, diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts index 542f6b987594..e76c29eee1e2 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.test.ts @@ -4,7 +4,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; @@ -17,21 +16,6 @@ const resolveWindowsUserData = (appDataDirectory: string) => }).pipe(Effect.provide(DesktopPreReadyFileSystem.layer)); it.layer(NodeServices.layer)("DesktopPreReadyFileSystem", (it) => { - it.effect("migrates the legacy Windows profile state", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pre-ready-fs-" }); - yield* fileSystem.makeDirectory(path.join(root, "T3 Code (Alpha)")); - yield* fileSystem.writeFileString(path.join(root, "T3 Code (Alpha)", "Local State"), "keys"); - - const userData = yield* resolveWindowsUserData(root); - - assert.equal(userData, path.join(root, "t3code-v2")); - assert.equal(yield* fileSystem.readFileString(path.join(userData, "Local State")), "keys"); - }), - ); - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( "fails instead of treating an unreadable profile as missing", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index c4e13b4de524..143a6e5fd87c 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -11,36 +11,29 @@ const { hasSwitchMock, registerSchemesMock, setDesktopNameMock, - setNameMock, - appState, mkdirSyncMock, - readFileSyncMock, writeFileSyncMock, copyFileSyncMock, execFileSyncMock, + readFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), hasSwitchMock: vi.fn(), registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), - setNameMock: vi.fn(), - appState: { version: "0.0.37", isPackaged: true }, mkdirSyncMock: vi.fn(), - readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, - setName: setNameMock, - getVersion: () => appState.version, - get isPackaged() { - return appState.isPackaged; - }, + getVersion: () => "0.0.37", + isPackaged: true, getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, @@ -66,11 +59,6 @@ vi.mock("node:child_process", () => ({ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; -const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; -const iconPath = "/xdg/icons/com.t3tools.T3Code.desktop.png"; -const desktopEntryPath = "/xdg/applications/com.t3tools.T3Code.desktop"; -const iconBytes = Buffer.from([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); - describe("DesktopPreReadyPlatform", () => { beforeEach(() => { appendSwitchMock.mockReset(); @@ -78,19 +66,11 @@ describe("DesktopPreReadyPlatform", () => { hasSwitchMock.mockReset(); registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); - setNameMock.mockReset(); - appState.version = "0.0.37"; - appState.isPackaged = true; mkdirSyncMock.mockReset(); - readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); - readFileSyncMock.mockReset().mockImplementation((path: string) => { - if (path.endsWith("/desktop-settings.json")) return "{}"; - if (path === iconSource) return Buffer.from(iconBytes); - throw new Error(`missing file: ${path}`); - }); + readFileSyncMock.mockReset().mockReturnValue("{}"); }); it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { @@ -139,88 +119,6 @@ describe("DesktopPreReadyPlatform", () => { ); }); - it.effect.each(["darwin", "win32"] as const)("leaves device scaling alone on %s", (platform) => { - readFileSyncMock.mockReturnValue("1.75"); - hasSwitchMock.mockImplementation((name: string) => name === "force-device-scale-factor"); - getSwitchValueMock.mockReturnValue("2"); - - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - assert.deepEqual(readFileSyncMock.mock.calls, []); - assert.deepEqual(writeFileSyncMock.mock.calls, []); - assert.deepEqual(appendSwitchMock.mock.calls, []); - }).pipe(Effect.provideService(HostProcess.Platform, platform)); - }); - - it.effect("preserves an explicit Linux password-store switch", () => { - hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); - getSwitchValueMock.mockReturnValue(" basic "); - - return Effect.gen(function* () { - const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; - - assert.equal(options.linuxPasswordStoreCommandLine, "basic"); - assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); - }).pipe( - Effect.provide( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), - ), - ), - ); - }); - - it.effect.each([ - { - label: "merges the color-manager fallback into disabled features on Hyprland 0.48.1", - switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, - hyprlandVersion: "0.48.1", - hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - expected: [["disable-features", "Vulkan,WaylandWpColorManagerV1"]], - }, - { - label: "preserves color management on Hyprland 0.51.0", - switches: { "ozone-platform": "wayland", "disable-features": "Vulkan" }, - hyprlandVersion: "0.51.0", - hyprlandCommit: "0123456789abcdef0123456789abcdef01234567", - expected: [], - }, - { - label: "preserves color management when Electron resolved X11", - switches: { "ozone-platform": "x11", "disable-features": "Vulkan" }, - hyprlandVersion: "0.48.1", - hyprlandCommit: "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc", - expected: [], - }, - ])("$label", ({ switches, hyprlandVersion, hyprlandCommit, expected }) => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const commandLine = new Map(Object.entries(switches)); - hasSwitchMock.mockImplementation((switchName: string) => commandLine.has(switchName)); - getSwitchValueMock.mockImplementation( - (switchName: string) => commandLine.get(switchName) ?? "", - ); - execFileSyncMock.mockReturnValue( - JSON.stringify({ - commit: hyprlandCommit, - version: hyprlandVersion, - dirty: false, - tag: `v${hyprlandVersion}`, - }), - ); - - return DesktopPreReadyPlatform.make.pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.map(() => { - const featureSwitches = appendSwitchMock.mock.calls.filter(([name]) => - ["disable-features", "enable-features", "ozone-platform"].includes(name), - ); - assert.deepEqual(featureSwitches, expected); - }), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }); - it.effect.each([ { previousEntry: undefined, label: "missing" }, { previousEntry: 'Exec="/Applications/deleted-previous.AppImage" %U', label: "stale" }, @@ -232,13 +130,14 @@ describe("DesktopPreReadyPlatform", () => { let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); - writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => { - if (path === iconPath) - iconInstalled = Buffer.isBuffer(contents) && contents.equals(iconBytes); - if (path === desktopEntryPath && typeof contents === "string") desktopEntry = contents; + writeFileSyncMock.mockImplementation((path: string, contents: string) => { + if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; }); return Effect.scoped( @@ -263,120 +162,10 @@ describe("DesktopPreReadyPlatform", () => { "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", ); assert.isTrue(identity.iconInstalled); - assert.deepEqual( - readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), - [[iconSource]], - ); - assert.deepEqual( - writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - [[iconPath, iconBytes, { mode: 0o600 }]], - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); - it.effect.each([ - { platform: "linux" as const, version: "0.1.0", isPackaged: true }, - { platform: "darwin" as const, version: "0.1.1-preview.20261017.3", isPackaged: false }, - { platform: "win32" as const, version: "0.1.0", isPackaged: true }, - ])( - "preserves the $platform encryption identity before startup yields", - ({ platform, version, isPackaged }) => { - appState.version = version; - appState.isPackaged = isPackaged; - getSwitchValueMock.mockReturnValue(""); - let appName = "T3 Code (Beta)"; - setNameMock.mockImplementation((name: string) => { - appName = name; - }); - return Effect.scoped( - Effect.gen(function* () { - const nameBeforeYield = Promise.resolve().then(() => appName); - yield* Layer.build( - DesktopPreReadyPlatform.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, platform)), - ), - ); - assert.equal( - yield* Effect.promise(() => nameBeforeYield), - platform === "win32" ? "T3 Code (Beta)" : "T3 Code (Alpha)", - ); - }), - ); - }, - ); - - it.effect("keeps startup available when the early desktop entry cannot be written", () => { - getSwitchValueMock.mockReturnValue(""); - mkdirSyncMock.mockImplementation(() => { - throw new Error("read-only filesystem"); - }); - - return DesktopPreReadyPlatform.make.pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.asVoid, - ); - }); - - it.effect("refreshes a stale icon once across repeated launches", () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - getSwitchValueMock.mockReturnValue(""); - let installedIcon = Buffer.from([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]); - readFileSyncMock.mockImplementation((path: string) => { - if (path === iconSource) return Buffer.from(iconBytes); - if (path === iconPath) return Buffer.from(installedIcon); - return "{}"; - }); - writeFileSyncMock.mockImplementation((path: string, contents: Buffer) => { - if (path === iconPath) installedIcon = Buffer.from(contents); - }); - - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - assert.deepEqual(installedIcon, iconBytes); - yield* DesktopPreReadyPlatform.make; - assert.deepEqual( - writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - [[iconPath, iconBytes, { mode: 0o600 }]], - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.equal( - writeFileSyncMock.mock.calls.filter(([path]) => path === desktopEntryPath).length, - 2, - ); - assert.equal(setDesktopNameMock.mock.calls.length, 2); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }); - - it.effect("still prepares the portal entry when the bundled icon cannot be read", () => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - getSwitchValueMock.mockReturnValue(""); - readFileSyncMock.mockImplementation((path: string) => { - if (path === iconSource) throw new Error("unreadable bundled icon"); - return "{}"; - }); - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - assert.isTrue(readFileSyncMock.mock.calls.some(([path]) => path === iconSource)); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.equal(writeFileSyncMock.mock.calls.length, 1); - const [path, contents] = writeFileSyncMock.mock.calls[0]!; - assert.equal(path, desktopEntryPath); - assert.include(contents, "MimeType=x-scheme-handler/t3code;"); - assert.include(contents, `Icon=${iconPath}`); - assert.equal(setDesktopNameMock.mock.calls.length, 1); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }); - it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 275367bab92a..58670439a525 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -10,7 +10,7 @@ import * as Electron from "electron"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; -import { resolveDesktopAppBranding, resolveDesktopSafeStorageName } from "./DesktopEnvironment.ts"; +import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -54,11 +54,6 @@ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { - // Chromium reads the safeStorage key's name before startup first yields. - // DesktopAppIdentity's later rename only changes the branded app name. - if (platform === "darwin" || platform === "linux") { - Electron.app.setName(resolveDesktopSafeStorageName(Electron.app.getVersion())); - } const linuxPasswordStoreCommandLine = platform === "linux" ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") @@ -97,21 +92,14 @@ export const make = Effect.gen(function* () { : undefined; if (iconPath !== undefined) { try { - // Electron's copyFile extracts ASAR entries to temporary files it never removes. - const icon = NodeFS.readFileSync( + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); + NodeFS.copyFileSync( NodePath.posix.join( Electron.app.getAppPath(), "apps/desktop/prod-resources/icon.png", ), + iconPath, ); - let current: Buffer | null = null; - try { - current = NodeFS.readFileSync(iconPath); - } catch {} - if (current === null || !current.equals(icon)) { - NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); - NodeFS.writeFileSync(iconPath, icon, { mode: 0o600 }); - } } catch {} } NodeFS.writeFileSync( diff --git a/apps/desktop/src/app/DesktopRuntime.ts b/apps/desktop/src/app/DesktopRuntime.ts deleted file mode 100644 index 17fc96b93b6e..000000000000 --- a/apps/desktop/src/app/DesktopRuntime.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Runtime from "effect/Runtime"; - -// DesktopLifecycle owns signals and waits for backend shutdown before quitting -// Electron. NodeRuntime's competing signal handler interrupts that sequence. -export const runMain = Runtime.makeRunMain(({ fiber, teardown }) => { - fiber.addObserver((exit) => { - teardown(exit, (code) => { - if (code !== 0) process.exit(code); - }); - }); -}); diff --git a/apps/desktop/src/app/DesktopSafeStorageIdentity.ts b/apps/desktop/src/app/DesktopSafeStorageIdentity.ts deleted file mode 100644 index d02fdb19f650..000000000000 --- a/apps/desktop/src/app/DesktopSafeStorageIdentity.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ChildProcessSpawner } from "effect/process"; - -import { decryptChromiumValue } from "../preview/BrowserImport/ChromiumCookies.ts"; -import { resolveChromiumKeys } from "../preview/BrowserImport/ChromiumKeys.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -export class DesktopSafeStorageIdentity extends Context.Service< - DesktopSafeStorageIdentity, - { - readonly decryptWithPreviousName: ( - encrypted: Uint8Array, - ) => Effect.Effect>; - } ->()("@t3tools/desktop/app/DesktopSafeStorageIdentity") {} - -const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const services = yield* Effect.context(); - - const decryptWithPreviousName = Effect.fn("desktop.safeStorageIdentity.decryptWithPreviousName")( - function* (encrypted: Uint8Array) { - if (environment.platform !== "darwin" && environment.platform !== "linux") { - return Option.none(); - } - if ( - DesktopEnvironment.resolveDesktopSafeStorageName(environment.appVersion) !== - "T3 Code (Alpha)" - ) { - return Option.none(); - } - // Cookie imports also accept plaintext; safeStorage requires an OSCrypt prefix. - const prefix = Buffer.from(encrypted.subarray(0, 3)).toString("latin1"); - if (prefix !== (environment.platform === "darwin" ? "v10" : "v11")) { - return Option.none(); - } - const keys = yield* resolveChromiumKeys({ - platform: environment.platform, - keychainService: "T3 Code (Beta) Safe Storage", - keychainAccount: "T3 Code (Beta)", - linuxSecretApplication: "T3 Code (Beta)", - }).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not read the Beta safe storage key.", { - reason: error.reason, - }), - ), - Effect.option, - ); - return Option.flatMapNullishOr(keys, (material) => - decryptChromiumValue(encrypted, material, "", undefined, environment.platform), - ); - }, - Effect.provide(services), - ); - - return DesktopSafeStorageIdentity.of({ decryptWithPreviousName }); -}); - -export const layer = Layer.effect(DesktopSafeStorageIdentity, make); diff --git a/apps/desktop/src/app/DesktopUserData.test.ts b/apps/desktop/src/app/DesktopUserData.test.ts index 41e497463a0d..78bab4085627 100644 --- a/apps/desktop/src/app/DesktopUserData.test.ts +++ b/apps/desktop/src/app/DesktopUserData.test.ts @@ -3,40 +3,9 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { resolveUserDataPath } from "./DesktopUserData.ts"; -it.effect("identifies a failed source read and preserves its cause", () => { - const sourceState = "/profiles/t3code/Local State"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: sourceState, - }); - return Effect.gen(function* () { - const error = yield* resolveUserDataPath({ - appDataDirectory: "/profiles", - isDevelopment: false, - platform: "win32", - }).pipe(Effect.flip); - assert.equal(error.operation, "read"); - assert.equal(error.resourcePath, sourceState); - assert.equal(error.category, "PermissionDenied"); - assert.strictEqual(error.cause, cause); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: (path) => Effect.succeed(path === sourceState), - readFileString: () => Effect.fail(cause), - }), - ), - Effect.provide(NodeServices.layer), - ); -}); - it.effect.each(["t3code", "T3 Code (Alpha)"])( "preserves Windows credential keys from %s without copying browser databases", (sourceName) => diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 8c97b5fe8356..9e87cfb7a9c0 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -36,22 +36,6 @@ const makeWindow = () => { }; describe("DesktopWebLinks", () => { - it("treats only http and https as web links", () => { - assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/")); - assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1")); - assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome")); - assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com")); - assert.isFalse(DesktopWebLinks.isWebLink("not a url")); - }); - - it("opens HTML files the way a browser does, and nothing else by path", () => { - assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html")); - assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM")); - assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml")); - assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md")); - assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html")); - }); - it.effect("holds links until the renderer listens, then delivers them in order", () => { const { sent, revealed, layer } = makeWindow(); return Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index ec92473d80a3..8d70ab4c4791 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -5,12 +5,9 @@ import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -22,22 +19,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; -const PersistedServerObservabilitySettingsDocument = Schema.Struct({ - observability: Schema.Struct({ - otlpTracesUrl: Schema.String, - otlpMetricsUrl: Schema.String, - otlpLogsUrl: Schema.String, - }), -}); - -const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( - Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), -); - -const isDesktopBackendObservabilitySettingsReadError = Schema.is( - DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, -); - const layerShellEnvironment = (prepared = true) => Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, { installIntoProcess: Effect.succeed(prepared), @@ -333,44 +314,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("only the local primary bootstrap claims the prepared shell environment", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); - assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); - }), - ), - ); - - it.effect("keeps server hydration enabled when desktop preparation failed", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const primary = yield* configuration.resolvePrimary; - assert.equal(primary.bootstrap.shellEnvironmentPrepared, false); - }), - false, - ), - ); - - it.effect("resolveWsl reuses the primary's bootstrap token", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - const primary = yield* configuration.resolvePrimary; - const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); - - assert.equal(wsl.bootstrap.desktopBootstrapToken, primary.bootstrap.desktopBootstrapToken); - }), - ), - ); - it.effect("resolveWsl pins a default-tracking run to the concrete default distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -616,71 +559,6 @@ describe("DesktopBackendConfiguration", () => { ); }); - it.effect("resolveWsl preserves the staging error when the mounted fallback is fatal", () => - withPackagedWslHarness( - { - archiveHash: "b".repeat(64), - wsl: () => ({ - prepareRuntime: () => ({ - ok: false, - reason: - "t3: error while loading shared libraries: libatomic.so.1: cannot open shared object file", - }), - ensureNodePty: () => ({ - ok: false, - reason: "the packaged Linux node-pty binary was not included", - fatal: true, - }), - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "libatomic.so.1"); - }), - ), - ); - - it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => { - const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; - const invalidatedRuntimeIds: string[] = []; - return withPackagedWslHarness( - { - archiveHash: "d".repeat(64), - wsl: () => ({ - prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), - invalidateRuntime: (_distro, runtimeId) => - Effect.sync(() => { - invalidatedRuntimeIds.push(runtimeId); - }), - probeRuntime: () => ({ - ok: false, - reason: "unsupported CPU architecture or incompatible system libraries", - }), - ensureNodePty: () => ({ - ok: false, - reason: "mounted tree is broken in some other way", - fatal: true, - }), - }), - }, - () => - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "unsupported CPU architecture"); - assert.deepEqual(invalidatedRuntimeIds, []); - }), - ); - }); - it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => { const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; const invalidatedRuntimeIds: string[] = []; @@ -800,226 +678,6 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolvePrimary surfaces persisted backend observability endpoints", () => - withHarness( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }), - ), - ); - - it.effect("resolvePrimary omits backend observability endpoints when settings are missing", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - }), - ), - ); - - it.effect("resolveWsl carries environment-configured observability endpoints", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); - assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge( - layerEnvironment(baseDir, { - platform: "win32", - otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", - otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", - otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("environment observability endpoints win over the persisted settings file", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - - yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { - recursive: true, - }); - yield* fileSystem.writeFileString( - environment.serverSettingsPath, - yield* encodePersistedServerObservabilitySettingsDocument({ - observability: { - otlpTracesUrl: "http://persisted:4318/v1/traces", - otlpMetricsUrl: "http://persisted:4318/v1/metrics", - otlpLogsUrl: "http://persisted:4318/v1/logs", - }, - }), - ); - - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); - assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); - assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("logs structured context when persisted observability settings cannot be read", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const settingsPath = path.join(baseDir, "userdata", "settings.json"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: settingsPath, - }); - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const layerFailingFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(cause), - }), - ); - - const config = yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - return yield* configuration.resolvePrimary; - }).pipe( - Effect.provide( - Layer.mergeAll( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge(layerEnvironment(baseDir)), - Layer.provideMerge(layerFailingFileSystem), - ), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - - assert.isUndefined(config.bootstrap.otlpTracesUrl); - assert.isUndefined(config.bootstrap.otlpMetricsUrl); - assert.isUndefined(config.bootstrap.otlpLogsUrl); - - const error = messages - .flatMap((message) => (Array.isArray(message) ? message : [message])) - .find(isDesktopBackendObservabilitySettingsReadError); - assert.isDefined(error); - assert.equal(error.settingsPath, settingsPath); - assert.equal(error.cause, cause); - assert.equal( - error.message, - `Failed to read persisted backend observability settings at ${settingsPath}.`, - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimary captures backend output in dev so child logs can be persisted", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.captureOutput, true); - assert.notInclude(config.args, "--require"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - isPackaged: false, - devServerUrl: "http://127.0.0.1:5733", - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl carries the kill switch into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1068,71 +726,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect( - "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - const standard = { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", - OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", - T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", - }; - const previousWslEnv = process.env.WSLENV; - const ambientOtel = Object.entries(process.env).filter( - ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), - ); - try { - for (const [name] of ambientOtel) delete process.env[name]; - delete process.env.WSLENV; - Object.assign(process.env, standard); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - - assert.equal( - config.env.OTEL_EXPORTER_OTLP_ENDPOINT, - "https://collector.example.com:4318/base?api_key=secret", - ); - assert.equal( - config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, - "authorization=Bearer%20token", - ); - const wslEnv = (config.env.WSLENV ?? "").split(":"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); - assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); - assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); - assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), - getDistroIp: () => Option.some("172.27.0.99"), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - } finally { - for (const name of Object.keys(standard)) delete process.env[name]; - restoreEnv("WSLENV", previousWslEnv); - for (const [name, value] of ambientOtel) restoreEnv(name, value); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1280,42 +873,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl keeps a transient distro-list failure retryable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "timed out"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distroListError: new DesktopWslEnvironment.DesktopWslDistroListError({ - reason: "wsl.exe --list --verbose timed out", - }), - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("resolveWsl marks a missing packaged server entry as fatal", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1349,113 +906,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isFalse(failure.fatal); - assert.equal(failure.retryLimit, 12); - assert.include(failure.reason, "could not be extracted"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge( - DesktopWslServerTree.layerTest({ - result: { - ok: false, - reason: "WSL server files could not be extracted", - fatal: false, - }, - }), - ), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolveWsl marks a missing selected distro as a fatal preflight failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolveWsl({ port: 5050, distro: "Removed-Distro" }); - const failure = Option.getOrThrow(config.preflightFailure); - - assert.isTrue(failure.fatal); - assert.include(failure.reason, "Removed-Distro"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge( - DesktopWslEnvironment.layerTest({ - isAvailable: true, - distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - }), - ), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the WSL distro when wsl-only and WSL is available", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "WSL (Ubuntu)"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1505,93 +955,6 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("prefers the release resource monitor when both development builds exist", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - const dirname = path.join(baseDir, "apps/desktop/src"); - const releaseMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/release/t3-resource-monitor", - ); - const debugMonitorPath = path.join( - baseDir, - "native/resource-monitor/target/debug/t3-resource-monitor", - ); - yield* fileSystem.makeDirectory(path.dirname(releaseMonitorPath), { recursive: true }); - yield* fileSystem.makeDirectory(path.dirname(debugMonitorPath), { recursive: true }); - yield* fileSystem.writeFileString(releaseMonitorPath, "release"); - yield* fileSystem.writeFileString(debugMonitorPath, "debug"); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const config = yield* configuration.resolvePrimary; - assert.equal(config.bootstrap.resourceMonitorPath, releaseMonitorPath); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(DesktopAppSettings.layerTest()), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest()), - Layer.provideMerge( - layerEnvironment(baseDir, { - dirname, - devServerUrl: "http://127.0.0.1:5733", - isPackaged: false, - }), - ), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resolvePrimaryLabel reports the local environment on non-Windows platforms", () => - withHarness( - Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Local environment"); - }), - ), - ); - - it.effect("resolvePrimaryLabel reports Windows when wsl-only but WSL is unavailable", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-config-test-", - }); - - yield* Effect.gen(function* () { - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const label = yield* configuration.resolvePrimaryLabel; - assert.equal(label, "Windows"); - }).pipe( - Effect.provide( - layerBackendConfiguration().pipe( - Layer.provideMerge(layerServerExposure), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: true, - wslDistro: "Ubuntu", - }), - ), - Layer.provideMerge(DesktopWslServerTree.layerTest()), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), - Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), - ), - ), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. const runtime = ManagedRuntime.make( diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 725779de4768..7b15346a754e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -24,8 +24,6 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopApp from "../app/DesktopApp.ts"; -import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -34,7 +32,6 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), ); -const isBackendProcessError = Schema.is(DesktopBackendManager.BackendProcessError); const encodeDesktopTelemetryControl = Schema.encodeSync( Schema.fromJsonString(DesktopTelemetryControlMessage), ); @@ -262,7 +259,7 @@ describe("DesktopBackendManager", () => { assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); assert.equal( Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 30_000, + 5_000, ); assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); @@ -274,163 +271,6 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("preserves the readiness timeout cause and process context", () => - Effect.gen(function* () { - const requested = yield* Deferred.make(); - const layer = Layer.merge( - TestClock.layer(), - layerHttpClient((request) => - Deferred.succeed(requested, request).pipe(Effect.andThen(Effect.never)), - ), - ); - - yield* Effect.gen(function* () { - const readiness = yield* DesktopBackendManager.waitForHttpReady({ - executablePath: baseConfig.executablePath, - entryPath: baseConfig.entryPath, - cwd: baseConfig.cwd, - httpBaseUrl: baseConfig.httpBaseUrl, - timeout: Duration.millis(50), - }).pipe(Effect.flip, Effect.forkChild); - - const request = yield* Deferred.await(requested); - assert.equal(request.url, "http://127.0.0.1:3773/.well-known/t3/environment"); - - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(readiness); - - assert.instanceOf(error, DesktopBackendManager.BackendReadinessTimeoutError); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.readinessUrl.href, "http://127.0.0.1:3773/.well-known/t3/environment"); - assert.equal(error.timeoutMs, 50); - assert.isDefined(error.cause); - assert.equal( - error.message, - "Timed out after 50ms waiting for desktop backend readiness at http://127.0.0.1:3773/.well-known/t3/environment.", - ); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("reports bootstrap encoding failures with stable process context", () => - Effect.gen(function* () { - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - bootstrap: { - ...baseConfig.bootstrap, - port: 0, - }, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessBootstrapEncodeError") { - return assert.fail(`Expected bootstrap encode error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.isDefined(error.cause); - assert.equal( - error.message, - "Failed to encode the desktop backend bootstrap payload for /server/bin.mjs.", - ); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves spawn failures without deriving their message from the cause", () => - Effect.gen(function* () { - const spawnCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcessSpawner", - method: "spawn", - pathOrDescriptor: baseConfig.executablePath, - description: "low-level detail that must not become the public message", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(spawnCause)), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessSpawnError") { - return assert.fail(`Expected backend spawn error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, spawnCause); - assert.equal( - error.message, - "Failed to spawn desktop backend entry /server/bin.mjs with /electron.", - ); - assert.notInclude(error.message, spawnCause.message); - assert.isTrue(isBackendProcessError(error)); - }), - ); - - it.effect("preserves exit-status failures without copying their detail into the message", () => - Effect.gen(function* () { - const exitCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "exitCode", - description: "exit-status-secret-sentinel", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Effect.fail(exitCause), - }), - ), - ), - ); - const error = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - }).pipe( - Effect.flip, - Effect.scoped, - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - ); - - if (error._tag !== "BackendProcessExitStatusError") { - return assert.fail(`Expected backend exit-status error, received ${error._tag}`); - } - assert.equal(error.pid, 123); - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.strictEqual(error.cause, exitCause); - assert.equal(error.message, "Failed to read the exit status of desktop backend process 123."); - assert.notInclude(error.message, "exit-status-secret-sentinel"); - assert.isTrue(isBackendProcessError(error)); - }), - ); - it.effect("reports a SIGKILL exit without an exit code", () => Effect.gen(function* () { const exitCause = PlatformError.systemError({ @@ -455,104 +295,6 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("reports output stream failures with process and stream context", () => - Effect.gen(function* () { - const outputCause = PlatformError.systemError({ - _tag: "BadResource", - module: "ChildProcess", - method: "stdout", - description: "output-stream-secret-sentinel", - }); - const reported = yield* Deferred.make(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fail(outputCause), - exitCode: Deferred.await(reported).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), - ), - ); - - const exit = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - const error = yield* Deferred.await(reported); - - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputReadError") { - return assert.fail(`Expected output read error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.strictEqual(error.cause, outputCause); - assert.equal(error.message, "Failed to read stdout from desktop backend process 123."); - assert.notInclude(error.message, "output-stream-secret-sentinel"); - }), - ); - - it.effect("reports output handler failures separately from stream read failures", () => - Effect.gen(function* () { - const chunk = new TextEncoder().encode("backend output"); - const nextChunk = new TextEncoder().encode("still draining"); - const outputCause = new Error("output-handler-secret-sentinel"); - const reported = yield* Deferred.make(); - const drained = yield* Deferred.make(); - let outputCount = 0; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.make(chunk, nextChunk), - exitCode: Deferred.await(drained).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), - ), - ); - - const exit = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onOutput: () => { - outputCount += 1; - return outputCount === 1 - ? Effect.fail(outputCause) - : Deferred.succeed(drained, void 0).pipe(Effect.asVoid); - }, - onOutputFailure: (error) => Deferred.succeed(reported, error).pipe(Effect.asVoid), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient))); - const error = yield* Deferred.await(reported); - - assert.equal(exit.code.pipe(Option.getOrUndefined), 0); - if (error._tag !== "BackendProcessOutputHandlingError") { - return assert.fail(`Expected output handling error, received ${error._tag}`); - } - assert.equal(error.executablePath, "/electron"); - assert.equal(error.entryPath, "/server/bin.mjs"); - assert.equal(error.cwd, "/server"); - assert.equal(error.httpBaseUrl.href, "http://127.0.0.1:3773/"); - assert.equal(error.pid, 123); - assert.equal(error.streamName, "stdout"); - assert.equal(error.chunkByteLength, chunk.byteLength); - assert.strictEqual(error.cause, outputCause); - assert.equal( - error.message, - `Failed to handle ${chunk.byteLength} bytes from stdout of desktop backend process 123.`, - ); - assert.notInclude(error.message, "output-handler-secret-sentinel"); - assert.equal(outputCount, 2); - }), - ); - it.effect("reports child exit before waiting for trailing output to drain", () => Effect.scoped( Effect.gen(function* () { @@ -631,195 +373,6 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("drains trailing child output before persisting an unexpected exit", () => - Effect.scoped( - Effect.gen(function* () { - const persistedOutput = yield* Deferred.make>(); - const outputDrainStarted = yield* Deferred.make(); - const outputChunks = yield* Ref.make>([]); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fromEffect( - Deferred.succeed(outputDrainStarted, void 0).pipe( - Effect.andThen(Effect.sleep(Duration.seconds(1))), - Effect.as(new TextEncoder().encode("trailing output\n")), - ), - ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), - ), - ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - writeOutputChunk: (_streamName, chunk) => - Ref.update(outputChunks, (current) => [...current, new TextDecoder().decode(chunk)]), - persistFailure: () => - Ref.get(outputChunks).pipe( - Effect.flatMap((chunks) => Deferred.succeed(persistedOutput, chunks)), - Effect.asVoid, - ), - }, - }); - - yield* instance.start; - yield* Deferred.await(outputDrainStarted); - yield* TestClock.adjust(Duration.seconds(1)); - - assert.deepEqual(yield* Deferred.await(persistedOutput), ["trailing output\n"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("retries HTTP readiness before reporting the backend ready", () => - Effect.scoped( - Effect.gen(function* () { - const requestUrls: Array = []; - const prunedRuntimes: Array<[string | null, string]> = []; - const statuses = [503, 200]; - let readyCount = 0; - const firstRequest = yield* Deferred.make(); - const backendReady = yield* Deferred.make(); - const processExit = yield* Deferred.make(); - const pruneComplete = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(processExit).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }), - ), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - runningDistro: "Ubuntu", - wslRuntimeId: "1.2.3-x64", - }, - pruneRuntimes: (distro, runtimeId) => - Effect.sync(() => { - prunedRuntimes.push([distro, runtimeId]); - }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid), - httpClientLayer: layerHttpClient((request) => - Effect.gen(function* () { - const status = statuses.shift(); - assert.isDefined(status); - requestUrls.push(request.url); - yield* Deferred.succeed(firstRequest, void 0); - return responseForRequest(request, status); - }), - ), - onReady: Effect.sync(() => { - readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid), - backendOutputLog: { - persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); - - yield* instance.start; - yield* Deferred.await(firstRequest); - - assert.equal(readyCount, 0); - assert.deepEqual(prunedRuntimes, []); - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); - - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(backendReady); - yield* Deferred.await(pruneComplete); - yield* Deferred.succeed(processExit, void 0); - yield* Queue.take(exited); - - assert.equal(readyCount, 1); - assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]); - assert.deepEqual(requestUrls, [ - "http://127.0.0.1:3773/.well-known/t3/environment", - "http://127.0.0.1:3773/.well-known/t3/environment", - ]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect( - "re-probes readiness after the first budget expires while the backend is still alive", - () => - Effect.scoped( - Effect.gen(function* () { - const requestUrls: Array = []; - let requestCount = 0; - let readyCount = 0; - let readinessTimeoutCount = 0; - const firstProbe = yield* Deferred.make(); - const childExit = yield* Deferred.make(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(childExit).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }), - ), - ), - ); - - const layerHttp = layerHttpClient((request) => - Effect.gen(function* () { - requestCount += 1; - requestUrls.push(request.url); - yield* Deferred.succeed(firstProbe, void 0); - return responseForRequest(request, requestCount <= 2 ? 503 : 200); - }), - ); - - const runFiber = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - readinessTimeout: Duration.millis(50), - onReady: () => - Effect.sync(() => { - readyCount += 1; - }), - onReadinessFailure: () => - Effect.sync(() => { - readinessTimeoutCount += 1; - }), - }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHttp)), Effect.forkChild); - - yield* Deferred.await(firstProbe); - assert.equal(readyCount, 0); - assert.equal(readinessTimeoutCount, 0); - - yield* TestClock.adjust(Duration.millis(50)); - assert.equal(readinessTimeoutCount, 1); - assert.equal(readyCount, 0); - - yield* TestClock.adjust(Duration.millis(100)); - assert.equal(readinessTimeoutCount, 2); - assert.equal(readyCount, 1); - assert.equal(requestUrls.length, 3); - - yield* Deferred.succeed(childExit, void 0); - assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.scoped( Effect.gen(function* () { @@ -990,254 +543,27 @@ describe("DesktopBackendManager", () => { ), ); - it.effect("retries config resolution after a start request during stop teardown", () => + it.effect("reports state-directory ownership once without scheduling another backend", () => Effect.scoped( Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - const configAttempts = yield* Ref.make(0); let startCount = 0; - - const configFailure = PlatformError.systemError({ - _tag: "Unknown", - module: "DesktopBackendManager", - method: "configResolve", - description: "transient configuration failure", - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, + const refused = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - const configResolve = Ref.updateAndGet(configAttempts, (attempt) => attempt + 1).pipe( - Effect.flatMap((attempt) => - attempt === 2 ? Effect.fail(configFailure) : Effect.succeed(baseConfig), + }); + }), + ), ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - configResolve, httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - - const pendingRestart = yield* instance.snapshot; - assert.equal(pendingRestart.desiredRunning, true); - assert.equal(pendingRestart.restartScheduled, true); - - yield* TestClock.adjust(Duration.seconds(2)); - - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - assert.equal(yield* Ref.get(configAttempts), 3); - assert.equal((yield* instance.snapshot).desiredRunning, true); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("keeps a timed-out run active until its process exits", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, - ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - const stopFiber = yield* instance - .stop({ timeout: Duration.millis(100) }) - .pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - yield* TestClock.adjust(Duration.millis(100)); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - - assert.equal(startCount, 1); - const timedOut = yield* instance.snapshot; - assert.equal(timedOut.desiredRunning, true); - assert.deepEqual(timedOut.activePid, Option.some(123)); - - yield* Deferred.succeed(finishTeardown, undefined); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("does not notify shutdown before the first start has prior state", () => - Effect.scoped( - Effect.gen(function* () { - let shutdownCount = 0; - const closed = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - yield* Queue.offer(startedPids, 123); - const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - onShutdown: Effect.sync(() => { - shutdownCount += 1; - }), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(startedPids), 123); - assert.equal(shutdownCount, 0); - }), - ), - ); - - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const failures = yield* Queue.unbounded(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: ({ details }) => Queue.offer(failures, details).pipe(Effect.asVoid), - }, - }); - - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 1); - assert.equal(yield* Queue.take(failures), "pid=123 code=1"); - - yield* TestClock.adjust(Duration.millis(499)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 2); - - yield* TestClock.adjust(Duration.millis(999)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 3); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("reports state-directory ownership once without scheduling another backend", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - const refused = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Effect.succeed( - ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), - ), - }); - }), - ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), + onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), }); yield* instance.start; yield* Deferred.await(refused); @@ -1249,372 +575,4 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); - - it.effect("does not notify shutdown when a scheduled restart starts from non-ready state", () => - Effect.scoped( - Effect.gen(function* () { - let shutdownCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "preflight failed", fatal: false }), - }, - onShutdown: Effect.sync(() => { - shutdownCount += 1; - }), - }); - - yield* instance.start; - assert.equal(shutdownCount, 0); - - yield* TestClock.adjust(Duration.millis(500)); - assert.equal(shutdownCount, 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("surfaces a fatal preflight failure once and stops looping after the cap", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "Node.js not found", fatal: true }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - assert.deepEqual(failures, []); - - yield* TestClock.adjust(Duration.millis(500)); - yield* TestClock.adjust(Duration.seconds(1)); - yield* TestClock.adjust(Duration.seconds(2)); - yield* TestClock.adjust(Duration.seconds(4)); - assert.deepEqual(failures, ["Node.js not found"]); - - yield* TestClock.adjust(Duration.seconds(8)); - yield* TestClock.adjust(Duration.seconds(30)); - assert.deepEqual(failures, ["Node.js not found"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("can be started again after a fatal preflight cap once config recovers", () => - Effect.scoped( - Effect.gen(function* () { - const failing = yield* Ref.make(true); - const starts = yield* Queue.unbounded(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Queue.offer(starts, 123).pipe( - Effect.as( - makeProcess({ - exitCode: Effect.never, - }), - ), - ), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - configResolve: Ref.get(failing).pipe( - Effect.map((isFailing) => - isFailing - ? { - ...baseConfig, - preflightFailure: Option.some({ - reason: "Node.js not found", - fatal: true, - }), - } - : baseConfig, - ), - ), - }); - - yield* instance.start; - yield* TestClock.adjust(Duration.millis(500)); - yield* TestClock.adjust(Duration.seconds(1)); - yield* TestClock.adjust(Duration.seconds(2)); - yield* TestClock.adjust(Duration.seconds(4)); - yield* TestClock.adjust(Duration.seconds(8)); - - const parked = yield* instance.snapshot; - assert.equal(parked.desiredRunning, false); - assert.equal(parked.ready, false); - assert.isTrue(Option.isNone(parked.activePid)); - assert.equal(parked.restartScheduled, false); - assert.equal(yield* Queue.size(starts), 0); - - yield* Ref.set(failing, false); - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 123); - const running = yield* instance.snapshot; - assert.equal(running.desiredRunning, true); - assert.deepEqual(running.activePid, Option.some(123)); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("keeps retrying a transient (non-fatal) preflight failure without surfacing", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ reason: "wslpath conversion failed", fatal: false }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - - yield* TestClock.adjust(Duration.minutes(2)); - assert.deepEqual(failures, []); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("surfaces a bounded transient preflight failure after its retry limit", () => - Effect.scoped( - Effect.gen(function* () { - const failures: string[] = []; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - config: { - ...baseConfig, - preflightFailure: Option.some({ - reason: "WSL toolchain probe timed out", - fatal: false, - retryLimit: 3, - }), - }, - onPreflightFailed: (failure) => - Effect.sync(() => { - failures.push(failure.reason); - }).pipe(Effect.as(false)), - }); - - yield* instance.start; - yield* TestClock.adjust(Duration.millis(500)); - assert.deepEqual(failures, []); - - yield* TestClock.adjust(Duration.seconds(1)); - assert.deepEqual(failures, ["WSL toolchain probe timed out"]); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("cancels a scheduled restart when start is requested manually", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const secondClosed = yield* Deferred.make(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - startCount += 1; - yield* Queue.offer(starts, startCount); - - if (startCount === 1) { - return makeProcess({ - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }); - } - - const scope = yield* Scope.Scope; - const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); - yield* Scope.addFinalizer(scope, close); - return makeProcess({ - exitCode: Deferred.await(secondClosed).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - kill: () => close, - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - - assert.equal(yield* Queue.take(starts), 1); - let restartScheduled = false; - while (!restartScheduled) { - restartScheduled = (yield* instance.snapshot).restartScheduled; - if (!restartScheduled) { - yield* Effect.yieldNow; - } - } - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 2); - - yield* instance.stop(); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.size(starts), 0); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("does not restart after stop cancels a scheduled restart", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - let startCount = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), - }); - }), - ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); - - let restartScheduled = false; - while (!restartScheduled) { - restartScheduled = (yield* instance.snapshot).restartScheduled; - if (!restartScheduled) { - yield* Effect.yieldNow; - } - } - - yield* instance.stop(); - yield* TestClock.adjust(Duration.millis(500)); - - assert.equal(yield* Queue.size(starts), 0); - assert.equal((yield* instance.snapshot).desiredRunning, false); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); - - it.effect("stopAllPoolInstances allows slow cleanup but bounds hung backends", () => - Effect.scoped( - Effect.gen(function* () { - const teardownStarted = yield* Queue.unbounded(); - const teardownFinished = yield* Queue.unbounded(); - const allowSlowTeardown = yield* Deferred.make(); - const allowHungTeardown = yield* Deferred.make(); - - const makeInstance = (name: string, allowTeardown: Deferred.Deferred) => - makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - yield* Scope.addFinalizer( - scope, - Queue.offer(teardownStarted, name).pipe( - Effect.andThen(Deferred.await(allowTeardown)), - Effect.andThen(Queue.offer(teardownFinished, name)), - Effect.asVoid, - ), - ); - return makeProcess({ exitCode: Effect.never }); - }), - ), - ), - httpClientLayer: layerHttpClient(() => Effect.never), - }); - - const instance1 = yield* makeInstance("instance1", allowSlowTeardown); - const instance2 = yield* makeInstance("instance2", allowHungTeardown); - - yield* instance1.start; - yield* instance2.start; - - const layerMockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { - list: Effect.succeed([instance1, instance2]), - get: () => Effect.succeedNone, - primary: Effect.die(new Error("primary not implemented")), - register: () => Effect.die(new Error("register not implemented")), - unregister: () => Effect.die(new Error("unregister not implemented")), - }); - - const quitFiber = yield* Effect.scoped( - Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), - ).pipe(Effect.provide(layerMockPool), Effect.forkChild); - - const started = yield* Queue.takeN(teardownStarted, 2); - yield* Effect.addFinalizer(() => - Deferred.succeed(allowSlowTeardown, undefined).pipe( - Effect.andThen(Deferred.succeed(allowHungTeardown, undefined)), - ), - ); - assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - - yield* TestClock.adjust(Duration.seconds(6)); - assert.isUndefined(quitFiber.pollUnsafe()); - yield* Deferred.succeed(allowSlowTeardown, undefined); - assert.equal(yield* Queue.take(teardownFinished), "instance1"); - assert.isUndefined(quitFiber.pollUnsafe()); - yield* TestClock.adjust(Duration.seconds(39)); - - yield* Fiber.join(quitFiber); - assert.equal(yield* Queue.size(teardownFinished), 0); - - yield* Deferred.succeed(allowHungTeardown, undefined); - assert.equal(yield* Queue.take(teardownFinished), "instance2"); - }).pipe(Effect.provide(TestClock.layer())), - ), - ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 50587ac26f87..b4057f4d0194 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -37,8 +37,9 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Give busy backends time to stop provider sessions before forcing termination. -const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(30); +// Same as the service launcher. A killed backend leaves its agents running, +// and a busy one needs a few seconds to stop every provider session. +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; const { logWarning: logBackendProcessWarning } = @@ -311,7 +312,7 @@ const closeRun = ( ); }; -export const waitForHttpReady = ( +const waitForHttpReady = ( options: BackendProcessContext & { readonly timeout: Duration.Duration }, ): Effect.Effect => { const readinessUrl = new URL(BACKEND_READINESS_PATH, options.httpBaseUrl); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index b4e2fbe9f14b..a8576a2b9e99 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -88,22 +88,6 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { - it.effect("resolves the primary label lazily after pool layer construction", () => - Effect.scoped( - Effect.gen(function* () { - const labelRef = yield* Ref.make("Windows"); - const pool = yield* DesktopBackendPool.DesktopBackendPool.pipe( - Effect.provide(layerPool(labelRef)), - ); - const primary = yield* pool.primary; - - yield* Ref.set(labelRef, "WSL (Ubuntu)"); - - assert.equal(yield* primary.label, "WSL (Ubuntu)"); - }), - ), - ); - it.effect("unregister stops the registered instance", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 900afce9b860..348d3a7050f7 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -195,21 +195,6 @@ describe("DesktopLocalEnvironmentAuth", () => { return { auth, requestCount }; }); - it.effect("retries a backend that is still starting", () => - Effect.gen(function* () { - const { auth, requestCount } = yield* makeExchange( - 2, - () => new Response("", { status: 503 }), - ); - - const fiber = yield* auth.getBearerToken.pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(1)); - - assert.strictEqual(yield* Fiber.join(fiber), "desktop-bearer-token"); - assert.strictEqual(yield* Ref.get(requestCount), 3); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("does not retry a rejected bootstrap credential", () => Effect.gen(function* () { const { auth, requestCount } = yield* makeExchange(1, () => diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts deleted file mode 100644 index 679d19a53d0a..000000000000 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; - -const { networkInterfacesMock } = vi.hoisted(() => ({ - networkInterfacesMock: vi.fn(), -})); - -vi.mock("node:os", () => ({ - networkInterfaces: networkInterfacesMock, -})); - -import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; - -const layerTest = DesktopNetworkInterfaces.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), -); - -describe("DesktopNetworkInterfaces", () => { - beforeEach(() => { - networkInterfacesMock.mockReset(); - }); - - it.effect("reads network interfaces through the service", () => { - const interfaces = { - en0: [ - { - address: "192.168.1.10", - family: "IPv4", - internal: false, - }, - ], - }; - networkInterfacesMock.mockReturnValueOnce(interfaces); - - return Effect.gen(function* () { - const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; - assert.strictEqual(yield* service.read, interfaces); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("preserves network interface read failures as structured defects", () => { - const cause = new Error("network interface probe failed"); - networkInterfacesMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; - const exit = yield* Effect.exit(service.read); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopNetworkInterfaces.DesktopNetworkInterfacesReadError); - assert.equal(error.platform, "linux"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read desktop network interfaces on linux."); - } - }).pipe(Effect.provide(layerTest)); - }); -}); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 75e1c8609a60..f53cde3e451d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -66,13 +66,6 @@ function layerMockSpawner(statusJson: string | (() => string) = "{}", onSpawn?: ); } -function layerDieOnSpawn() { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), - ); -} - function layerEnvironmentFor(baseDir: string, env: Record = {}) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -198,20 +191,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("returns a typed error when network access is explicitly unavailable", () => - withHarness( - emptyNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); - assert.equal(error.port, 4173); - }), - ), - ); - it.effect("persists network-accessible mode and updates backend binding state", () => withHarness( lanNetworkInterfaces, @@ -259,22 +238,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("keeps a host bridge's LAN address ahead of later adapters", () => - withHarness( - { - vmbr0: [{ address: "192.168.1.30", family: "IPv4", internal: false }], - enx00e04c680001: [{ address: "192.168.42.10", family: "IPv4", internal: false }], - }, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "192.168.1.30"); - }), - ), - ); - it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => withHarness( emptyNetworkInterfaces, @@ -306,71 +269,6 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("preserves persistence request context and the settings failure chain", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/desktop-settings.json", - cause: diskFailure, - }); - const layerSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.fail(settingsFailure), - setTailscaleServe: () => Effect.fail(settingsFailure), - setUpdateChannel: () => Effect.die("unexpected update channel change"), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); - - return withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.instanceOf( - modeError, - DesktopServerExposure.DesktopServerExposureModePersistenceError, - ); - assert.equal(modeError.mode, "network-accessible"); - assert.strictEqual(modeError.cause, settingsFailure); - assert.strictEqual(modeError.cause.cause, diskFailure); - assert.equal( - modeError.message, - "Failed to persist desktop server exposure mode network-accessible.", - ); - assert.notInclude(modeError.message, diskFailure.message); - - const tailscaleError = yield* serverExposure - .setTailscaleServeEnabled({ enabled: true, port: 8443 }) - .pipe(Effect.flip); - assert.instanceOf( - tailscaleError, - DesktopServerExposure.DesktopTailscaleServePersistenceError, - ); - assert.equal(tailscaleError.enabled, true); - assert.equal(tailscaleError.port, 8443); - assert.strictEqual(tailscaleError.cause, settingsFailure); - assert.strictEqual(tailscaleError.cause.cause, diskFailure); - assert.equal( - tailscaleError.message, - "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", - ); - assert.notInclude(tailscaleError.message, diskFailure.message); - }), - {}, - undefined, - layerSettings, - ); - }); - it.effect("keeps a Tailscale Serve change made while a mode change is saving", () => Effect.gen(function* () { const modeWriteStarted = yield* Deferred.make(); @@ -522,24 +420,6 @@ describe("DesktopServerExposure", () => { }, ); - it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 4173 }); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/"], - ); - }), - {}, - layerDieOnSpawn(), - ), - ); - it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, @@ -563,104 +443,4 @@ describe("DesktopServerExposure", () => { }, ), ); - - it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* serverExposure.configureFromSettings({ port: 3773 }); - yield* serverExposure.setMode("network-accessible"); - - const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual(endpoints, [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.20:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.20:3773/", - wsBaseUrl: "ws://192.168.1.20:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }), - { - T3CODE_DESKTOP_HTTPS_ENDPOINTS: - "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", - }, - ), - ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index c3cdaaa317e6..054655917cec 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -4,10 +4,7 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; -import { - parseTailscaleMagicDnsName, - resolveTailscaleAdvertisedEndpoints, -} from "./tailscaleEndpointProvider.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; const layerUnusedTailscaleExternalServices = Layer.mergeAll( Layer.succeed( @@ -40,19 +37,6 @@ describe("tailscale endpoint provider", () => { }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); - it.effect( - "does not advertise CGNAT interfaces when Tailscale reports no assigned addresses", - () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, - statusJson: `{"BackendState":"NoState","Self":{"TailscaleIPs":null}}`, - }); - assert.deepEqual(endpoints, []); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - it.effect("keeps native Tailscale interfaces with missing, malformed, or stale status", () => Effect.gen(function* () { for (const interfaceName of ["tailscale0", "Tailscale"]) { @@ -95,165 +79,4 @@ describe("tailscale endpoint provider", () => { ); }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), ); - - it.effect("uses assigned addresses for renamed adapters without IPv6", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - vpn0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], - }, - statusJson: `{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, - }); - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://100.74.126.34:3773/"], - ); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("does not advertise cached Tailscale addresses absent from local interfaces", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1 }, - statusJson: `{"Self":{"TailscaleIPs":["100.74.126.35"]}}`, - }); - assert.deepEqual(endpoints, []); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("parses MagicDNS names from tailscale status", () => - Effect.gen(function* () { - const dnsName = yield* parseTailscaleMagicDnsName( - `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - ); - assert.equal(dnsName, "desktop.tail.ts.net"); - assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); - const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); - assert.isTrue(malformed._tag === "Failure"); - }), - ); - - it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: { - tailscale0: [ - { - address: "100.100.100.100", - family: "IPv4", - internal: false, - netmask: "255.192.0.0", - cidr: "100.100.100.100/10", - mac: "00:00:00:00:00:00", - }, - ], - }, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100"]}}`, - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-ip:http://100.100.100.100:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.100.100.100:3773/", - wsBaseUrl: "ws://100.100.100.100:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }, - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "requires-configuration", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "unavailable", - description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }, - ]); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect("uses one injected status reader for both IP and MagicDNS endpoints", () => - Effect.gen(function* () { - let readerCalls = 0; - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: protonAndTailscaleInterfaces, - readStatus: Effect.sync(() => { - readerCalls += 1; - return { - magicDnsName: "desktop.tail.ts.net", - tailnetIpv4Addresses: ["100.74.126.34"], - httpsServeEnabled: null, - }; - }), - }); - assert.equal(readerCalls, 1); - assert.deepEqual( - endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://100.74.126.34:3773/", "https://desktop.tail.ts.net/"], - ); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); - - it.effect( - "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", - () => - Effect.gen(function* () { - const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, - serveEnabled: true, - probe: () => Effect.succeed(true), - }); - assert.deepEqual(endpoints, [ - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "HTTPS endpoint served by Tailscale Serve.", - }, - ]); - }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)), - ); }); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index ec3819ae98af..044625a32b44 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -15,8 +15,6 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts"; -export { parseTailscaleMagicDnsName } from "@t3tools/tailscale"; - const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "tailscale", label: "Tailscale", diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts deleted file mode 100644 index 84f37b6f5f53..000000000000 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { - appendSwitchMock, - autoUpdaterOnMock, - autoUpdaterRemoveListenerMock, - exitMock, - getAppPathMock, - getSystemLocaleMock, - getVersionMock, - onMock, - quitMock, - relaunchMock, - removeListenerMock, - removeSwitchMock, - setAboutPanelOptionsMock, - setAppUserModelIdMock, - setAsDefaultProtocolClientMock, - setDesktopNameMock, - setDockIconMock, - setNameMock, - setPathMock, - whenReadyMock, -} = vi.hoisted(() => ({ - appendSwitchMock: vi.fn(), - autoUpdaterOnMock: vi.fn(), - autoUpdaterRemoveListenerMock: vi.fn(), - exitMock: vi.fn(), - getAppPathMock: vi.fn(() => "/app"), - getSystemLocaleMock: vi.fn(() => "en-GB"), - getVersionMock: vi.fn(() => "1.2.3"), - onMock: vi.fn(), - quitMock: vi.fn(), - relaunchMock: vi.fn(), - removeListenerMock: vi.fn(), - removeSwitchMock: vi.fn(), - setAboutPanelOptionsMock: vi.fn(), - setAppUserModelIdMock: vi.fn(), - setAsDefaultProtocolClientMock: vi.fn(() => true), - setDesktopNameMock: vi.fn(), - setDockIconMock: vi.fn(), - setNameMock: vi.fn(), - setPathMock: vi.fn(), - whenReadyMock: vi.fn(() => Promise.resolve()), -})); - -vi.mock("electron", () => ({ - autoUpdater: { - on: autoUpdaterOnMock, - removeListener: autoUpdaterRemoveListenerMock, - }, - app: { - commandLine: { - appendSwitch: appendSwitchMock, - removeSwitch: removeSwitchMock, - }, - dock: { - setIcon: setDockIconMock, - }, - getAppPath: getAppPathMock, - getSystemLocale: getSystemLocaleMock, - getVersion: getVersionMock, - isPackaged: true, - name: "T3 Code", - on: onMock, - quit: quitMock, - relaunch: relaunchMock, - removeListener: removeListenerMock, - runningUnderARM64Translation: false, - setAboutPanelOptions: setAboutPanelOptionsMock, - setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, - setAppUserModelId: setAppUserModelIdMock, - setDesktopName: setDesktopNameMock, - setName: setNameMock, - setPath: setPathMock, - whenReady: whenReadyMock, - exit: exitMock, - }, -})); - -import * as ElectronApp from "./ElectronApp.ts"; - -describe("ElectronApp", () => { - beforeEach(() => { - appendSwitchMock.mockClear(); - autoUpdaterOnMock.mockClear(); - autoUpdaterRemoveListenerMock.mockClear(); - exitMock.mockClear(); - onMock.mockClear(); - quitMock.mockClear(); - relaunchMock.mockClear(); - removeListenerMock.mockClear(); - removeSwitchMock.mockClear(); - setPathMock.mockClear(); - }); - - it.effect("reads app metadata through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const metadata = yield* electronApp.metadata; - - assert.deepEqual(metadata, { - appVersion: "1.2.3", - appPath: "/app", - isPackaged: true, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: false, - }); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("reads the OS locale through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => - Effect.gen(function* () { - getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); - const electronApp = yield* ElectronApp.ElectronApp; - - assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("reports which app metadata property failed", () => - Effect.gen(function* () { - const cause = new Error("version unavailable"); - getVersionMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronApp = yield* ElectronApp.ElectronApp; - const error = yield* electronApp.metadata.pipe(Effect.flip); - - assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError); - assert.strictEqual(error.property, "app-version"); - assert.strictEqual(error.cause, cause); - assert.strictEqual( - error.message, - 'Failed to read Electron app metadata property "app-version".', - ); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("preserves Electron readiness failures", () => - Effect.gen(function* () { - const cause = new Error("ready failed"); - whenReadyMock.mockRejectedValueOnce(cause); - - const electronApp = yield* ElectronApp.ElectronApp; - const error = yield* electronApp.whenReady.pipe(Effect.flip); - - assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError); - assert.strictEqual(error.isPackaged, true); - assert.strictEqual(error.cause, cause); - assert.strictEqual( - error.message, - "Failed to wait for the Electron app to become ready (packaged: true).", - ); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("scopes app event listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.on("activate", listener); - }), - ); - - assert.deepEqual(onMock.mock.calls, [["activate", listener]]); - assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("scopes native updater quit listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.onBeforeQuitForUpdate(listener); - }), - ); - - assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); - assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ - ["before-quit-for-update", listener], - ]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); - - it.effect("removes command-line switches through the service", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.removeCommandLineSwitch("password-store"); - - assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts deleted file mode 100644 index 2ed5a1f2f913..000000000000 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import type { BrowserWindow } from "electron"; -import { beforeEach, vi } from "vite-plus/test"; - -import * as ElectronDialog from "./ElectronDialog.ts"; - -const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), - showOpenDialogMock: vi.fn(), - showErrorBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - showOpenDialog: showOpenDialogMock, - showErrorBox: showErrorBoxMock, - }, -})); - -describe("ElectronDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - showOpenDialogMock.mockReset(); - showErrorBoxMock.mockReset(); - }); - - it.effect("preserves folder picker request context and cause", () => - Effect.gen(function* () { - const cause = new Error("folder picker failed"); - const owner = { id: 7 } as BrowserWindow; - showOpenDialogMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.pickFolder({ - owner: Option.some(owner), - defaultPath: Option.some("/workspace"), - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.strictEqual(error.ownerWindowId, 7); - assert.strictEqual(error.defaultPath, "/workspace"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "window 7"); - assert.include(error.message, "/workspace"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("opens a single-file picker when multiple selections are disabled", () => - Effect.gen(function* () { - showOpenDialogMock.mockResolvedValue({ - canceled: false, - filePaths: ["/pictures/icon.png"], - }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const paths = yield* dialog.pickFiles({ - owner: Option.none(), - defaultPath: Option.some("/project"), - filters: [{ name: "Images", extensions: ["png"] }], - multiple: false, - }); - - assert.deepEqual(paths, ["/pictures/icon.png"]); - assert.deepEqual(showOpenDialogMock.mock.calls, [ - [ - { - defaultPath: "/project", - filters: [{ name: "Images", extensions: ["png"] }], - properties: ["openFile"], - }, - ], - ]); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("preserves message box request context and cause", () => - Effect.gen(function* () { - const cause = new Error("message box failed"); - showMessageBoxMock.mockRejectedValue(cause); - const dialog = yield* ElectronDialog.ElectronDialog; - - const error = yield* Effect.flip( - dialog.showMessageBox({ - type: "warning", - title: "Unsaved changes", - message: "Discard changes?", - detail: "This cannot be undone.", - buttons: ["Cancel", "Discard"], - }), - ); - - assert.instanceOf(error, ElectronDialog.ElectronDialogShowMessageBoxError); - assert.strictEqual(error.type, "warning"); - assert.strictEqual(error.titleLength, "Unsaved changes".length); - assert.strictEqual(error.messageLength, "Discard changes?".length); - assert.strictEqual(error.detailLength, "This cannot be undone.".length); - assert.strictEqual(error.buttonCount, 2); - assert.notProperty(error, "title"); - assert.notProperty(error, "dialogMessage"); - assert.notProperty(error, "dialogDetail"); - assert.notProperty(error, "buttons"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "warning"); - assert.notInclude(error.message, "Unsaved changes"); - assert.notInclude(error.message, "Discard changes?"); - assert.notInclude(error.message, "This cannot be undone."); - assert.notInclude(error.message, "Cancel"); - assert.notInclude(error.message, "Discard"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); - - it.effect("preserves error box request context and cause in the defect", () => - Effect.gen(function* () { - const cause = new Error("error box failed"); - showErrorBoxMock.mockImplementation(() => { - throw cause; - }); - const dialog = yield* ElectronDialog.ElectronDialog; - - const exit = yield* Effect.exit(dialog.showErrorBox("Startup failed", "Could not start.")); - - assert.isTrue(exit._tag === "Failure"); - if (exit._tag === "Success") return; - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronDialog.ElectronDialogShowErrorBoxError); - assert.strictEqual(error.titleLength, "Startup failed".length); - assert.strictEqual(error.contentLength, "Could not start.".length); - assert.notProperty(error, "title"); - assert.notProperty(error, "content"); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, "Startup failed"); - assert.notInclude(error.message, "Could not start."); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronDialog.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts deleted file mode 100644 index 320988051e5f..000000000000 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import type * as Electron from "electron"; -import { beforeEach, vi } from "vite-plus/test"; - -const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( - () => ({ - buildFromTemplateMock: vi.fn(), - createFromNamedImageMock: vi.fn(), - setApplicationMenuMock: vi.fn(), - }), -); - -vi.mock("electron", () => ({ - Menu: { - buildFromTemplate: buildFromTemplateMock, - setApplicationMenu: setApplicationMenuMock, - }, - nativeImage: { - createFromNamedImage: createFromNamedImageMock, - }, -})); - -import * as ElectronMenu from "./ElectronMenu.ts"; - -const layerTest = ElectronMenu.layer.pipe( - Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), -); - -const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => - ({ - id: 7, - webContents: { getZoomFactor: () => zoomFactor }, - }) as unknown as Electron.BrowserWindow; - -describe("ElectronMenu", () => { - beforeEach(() => { - buildFromTemplateMock.mockReset(); - createFromNamedImageMock.mockReset(); - setApplicationMenuMock.mockReset(); - }); - - it.effect("returns none without building a menu when there are no valid items", () => - Effect.gen(function* () { - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, - items: [], - position: Option.none(), - }); - - assert.isTrue(Option.isNone(selectedItemId)); - assert.equal(buildFromTemplateMock.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("resolves with the clicked leaf item id", () => - Effect.gen(function* () { - buildFromTemplateMock.mockImplementation( - (template: Electron.MenuItemConstructorOptions[]) => ({ - popup: () => { - const firstItem = template[0]; - assert.isDefined(firstItem); - const click = firstItem.click; - if (!click) { - throw new Error("Expected menu item to have a click handler."); - } - click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - }, - }), - ); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: makeWindow(), - items: [{ id: "copy", label: "Copy" }], - position: Option.none(), - }); - - assert.equal(Option.getOrNull(selectedItemId), "copy"); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("resolves with none when the menu closes without a click", () => - Effect.gen(function* () { - let popupOptions: Electron.PopupOptions | undefined; - buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: Electron.PopupOptions) => { - popupOptions = options; - options.callback?.(); - }, - })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const selectedItemId = yield* electronMenu.showContextMenu({ - window: makeWindow(2), - items: [ - { id: "copy", label: "Copy" }, - { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, - ], - position: Option.some({ x: 10.8, y: 20.2 }), - }); - - assert.isTrue(Option.isNone(selectedItemId)); - assert.equal(popupOptions?.x, 21); - assert.equal(popupOptions?.y, 40); - assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { - label: "Copy", - enabled: true, - click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, - }); - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Delete"], - ); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("keeps a preceding non-destructive action in the destructive section", () => - Effect.gen(function* () { - buildFromTemplateMock.mockImplementation(() => ({ - popup: (options: Electron.PopupOptions) => options.callback?.(), - })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - yield* electronMenu.showContextMenu({ - window: makeWindow(), - items: [ - { id: "copy", label: "Copy" }, - { id: "archive", label: "Archive", separatorBefore: true }, - { id: "delete", label: "Delete", destructive: true }, - ], - position: Option.none(), - }); - - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Archive", "Delete"], - ); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("defers popupTemplate side effects until the returned Effect runs", () => - Effect.gen(function* () { - const popupMock = vi.fn(); - buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const frame = { routingId: 7 } as Electron.WebFrameMain; - const popup = electronMenu.popupTemplate({ - window: {} as Electron.BrowserWindow, - template: [{ label: "Copy" }], - frame, - }); - - assert.equal(buildFromTemplateMock.mock.calls.length, 0); - assert.equal(popupMock.mock.calls.length, 0); - - yield* popup; - - assert.equal(buildFromTemplateMock.mock.calls.length, 1); - assert.equal(popupMock.mock.calls.length, 1); - assert.strictEqual(popupMock.mock.calls[0]?.[0].frame, frame); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves application-menu failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("application menu build failed"); - buildFromTemplateMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "set-application-menu"); - assert.equal(error.platform, "linux"); - assert.isNull(error.windowId); - assert.equal(error.itemCount, 2); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves popup-template failures with window context", () => - Effect.gen(function* () { - const cause = new Error("popup failed"); - buildFromTemplateMock.mockReturnValueOnce({ - popup: () => { - throw cause; - }, - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.popupTemplate({ - window: { id: 41 } as Electron.BrowserWindow, - template: [{ label: "Copy" }], - }), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "popup-template"); - assert.equal(error.windowId, 41); - assert.equal(error.itemCount, 1); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves context-menu failures with normalized item context", () => - Effect.gen(function* () { - const cause = new Error("context menu build failed"); - buildFromTemplateMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - const exit = yield* Effect.exit( - electronMenu.showContextMenu({ - window: { id: 42 } as Electron.BrowserWindow, - items: [{ id: "copy", label: "Copy" }], - position: Option.none(), - }), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); - assert.equal(error.operation, "show-context-menu"); - assert.equal(error.windowId, 42); - assert.equal(error.itemCount, 1); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 4c9da1dd9259..45d1771f26f0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,11 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -156,173 +153,4 @@ describe("ElectronProtocol", () => { assert.equal(netFetchMock.mock.calls.length, 0); }).pipe(Effect.provide(layerProtocol)), ); - - it.effect("retries transient renderer target failures", () => - Effect.gen(function* () { - let handler: ((request: Request) => Promise) | undefined; - handleMock.mockImplementation((_scheme, nextHandler) => { - handler = nextHandler; - }); - netFetchMock - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733")) - .mockResolvedValueOnce(new Response("ready")); - - const response = yield* Effect.scoped( - Effect.gen(function* () { - const protocol = yield* ElectronProtocol.ElectronProtocol; - yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, - }); - const fiber = yield* Effect.forkChild( - Effect.promise(() => handler!(new Request("t3code-dev://app/"))), - ); - yield* TestClock.adjust("50 millis"); - return yield* Fiber.join(fiber); - }), - ); - - assert.equal(yield* Effect.promise(() => response.text()), "ready"); - assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () => - Effect.gen(function* () { - let handler: ((request: Request) => Promise) | undefined; - handleMock.mockImplementation((_scheme, nextHandler) => { - handler = nextHandler; - }); - const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)"); - netFetchMock - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)")) - .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)")) - .mockRejectedValueOnce(lastFailure); - - const rejection = yield* Effect.scoped( - Effect.gen(function* () { - const protocol = yield* ElectronProtocol.ElectronProtocol; - yield* protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:5733/"), - clerkFrontendApiHostname: undefined, - }); - const fiber = yield* Effect.forkChild( - Effect.promise(() => - handler!(new Request("t3code-dev://app/")).then( - () => null, - (error: unknown) => error, - ), - ), - ); - yield* TestClock.adjust("49 millis"); - assert.equal(netFetchMock.mock.calls.length, 1); - yield* TestClock.adjust("1 millis"); - assert.equal(netFetchMock.mock.calls.length, 2); - yield* TestClock.adjust("149 millis"); - assert.equal(netFetchMock.mock.calls.length, 2); - yield* TestClock.adjust("1 millis"); - return yield* Fiber.join(fiber); - }), - ); - - assert.strictEqual(rejection, lastFailure); - assert.equal(netFetchMock.mock.calls.length, 3); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("preserves protocol registration failures", () => - Effect.gen(function* () { - const cause = new Error("protocol registration failed"); - handleMock.mockImplementationOnce(() => { - throw cause; - }); - - const protocol = yield* ElectronProtocol.ElectronProtocol; - const error = yield* Effect.scoped( - protocol.registerDesktopProtocol({ - scheme: "t3code-dev", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, - }), - ).pipe(Effect.flip); - - assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); - assert.equal(error.scheme, "t3code-dev"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(layerProtocol)), - ); - - it.effect("preserves protocol unregistration failures", () => - Effect.gen(function* () { - const cause = new Error("protocol unregistration failed"); - unhandleMock.mockImplementationOnce(() => { - throw cause; - }); - - const protocol = yield* ElectronProtocol.ElectronProtocol; - const exit = yield* Effect.exit( - Effect.scoped( - protocol.registerDesktopProtocol({ - scheme: "t3code", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: undefined, - }), - ), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); - assert.equal(error.scheme, "t3code"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); - } - }).pipe(Effect.provide(layerProtocol)), - ); - - it("keeps executable sources host-restricted while allowing runtime network resources", () => { - const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ - scheme: "t3code", - targetOrigin: new URL("http://127.0.0.1:3773/"), - clerkFrontendApiHostname: "clerk.t3.codes", - }); - const directives = Object.fromEntries( - policy.split("; ").map((directive) => { - const [name, ...sources] = directive.split(" "); - return [name, sources]; - }), - ); - - assert.deepEqual(directives["script-src"], [ - "'self'", - "'unsafe-inline'", - "'wasm-unsafe-eval'", - "https://clerk.t3.codes", - "https://challenges.cloudflare.com", - ]); - assert.deepEqual(directives["connect-src"], [ - "'self'", - "blob:", - "http:", - "https:", - "ws:", - "wss:", - ]); - assert.deepEqual(directives["img-src"], [ - "'self'", - "t3code:", - "blob:", - "data:", - "http:", - "https:", - ]); - assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); - assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]); - assert.deepEqual(directives["object-src"], ["'self'", "blob:", "http:", "https:"]); - assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); - }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 9abe17d77512..d6f476205f4c 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -66,7 +66,7 @@ export class ElectronProtocol extends Context.Service< } >()("@t3tools/desktop/electron/ElectronProtocol") {} -export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { +function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { const clerkOrigin = input.clerkFrontendApiHostname ? `https://${input.clerkFrontendApiHostname}` : undefined; diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 3bfd6b7e07d0..531d37bf27af 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -24,54 +24,6 @@ describe("ElectronShell", () => { writeTextMock.mockReset(); }); - it.effect("opens safe external URLs", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("https://example.com/path"); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("copies text to the system clipboard", () => - Effect.gen(function* () { - writeTextMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText("https://example.com/path"); - - assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("does not fail when the clipboard write rejects", () => - Effect.gen(function* () { - writeTextMock.mockRejectedValue(new Error("write failed")); - - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText("https://example.com/path"); - - assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("opens the Full Disk Access settings anchor", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openSystemSettings("full-disk-access"); - - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [ - ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], - ]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -183,29 +135,4 @@ describe("ElectronShell", () => { assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); - - it.effect("does not open non-remote editor URLs", () => - Effect.gen(function* () { - openExternalMock.mockResolvedValue(undefined); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal( - "vscode://ms-python.python/some-command?argument=attacker", - ); - - assert.equal(result, false); - assert.equal(openExternalMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronShell.layer)), - ); - - it.effect("returns false when Electron rejects openExternal", () => - Effect.gen(function* () { - openExternalMock.mockRejectedValue(new Error("open failed")); - - const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("https://example.com/path"); - - assert.equal(result, false); - }).pipe(Effect.provide(ElectronShell.layer)), - ); }); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts deleted file mode 100644 index b4028930af66..000000000000 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ - onMock: vi.fn(), - removeListenerMock: vi.fn(), - themeState: { - shouldUseDarkColors: true, - themeSource: "system", - setSourceError: null as unknown, - }, -})); - -vi.mock("electron", () => ({ - nativeTheme: { - get shouldUseDarkColors() { - return themeState.shouldUseDarkColors; - }, - set themeSource(value: string) { - if (themeState.setSourceError !== null) { - throw themeState.setSourceError; - } - themeState.themeSource = value; - }, - on: onMock, - removeListener: removeListenerMock, - }, -})); - -import * as ElectronTheme from "./ElectronTheme.ts"; - -describe("ElectronTheme", () => { - beforeEach(() => { - onMock.mockClear(); - removeListenerMock.mockClear(); - themeState.shouldUseDarkColors = true; - themeState.themeSource = "system"; - themeState.setSourceError = null; - }); - - it.effect("scopes native theme update listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const electronTheme = yield* ElectronTheme.ElectronTheme; - yield* electronTheme.onUpdated(listener); - }), - ); - - assert.deepEqual(onMock.mock.calls, [["updated", listener]]); - assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); - }).pipe(Effect.provide(ElectronTheme.layer)), - ); - - it.effect("preserves the requested source and cause when setting the theme fails", () => - Effect.gen(function* () { - const cause = new Error("theme source failed"); - themeState.setSourceError = cause; - const electronTheme = yield* ElectronTheme.ElectronTheme; - - const error = yield* Effect.flip(electronTheme.setSource("dark")); - - assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.strictEqual(error.source, "dark"); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "dark"); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronTheme.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts deleted file mode 100644 index 969cd6fb40b6..000000000000 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Build real electron-updater disk fixtures with its SHA-512 encoding. -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeCrypto from "node:crypto"; -import * as NodeEvents from "node:events"; -import * as NodeModule from "node:module"; -import * as NodePath from "node:path"; -import * as Deferred from "effect/Deferred"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import { AppImageUpdater } from "electron-updater/out/AppImageUpdater.js"; -import { DebUpdater } from "electron-updater/out/DebUpdater.js"; -import { MacUpdater } from "electron-updater/out/MacUpdater.js"; -import { GenericProvider } from "electron-updater/out/providers/GenericProvider.js"; -import type { UpdateDownloadedEvent } from "electron-updater/out/types.js"; -import { NsisUpdater } from "electron-updater/out/NsisUpdater.js"; -import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; -import { flushCallbacks, makeHarness } from "../updates/updatesTestHarness.ts"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import type { VerifyUpdateCodeSignature } from "electron-updater"; -import { beforeEach, vi } from "vite-plus/test"; - -const { autoUpdaterMock, updaterOverride } = vi.hoisted(() => ({ - updaterOverride: { current: undefined as import("electron-updater").AppUpdater | undefined }, - autoUpdaterMock: { - allowDowngrade: false, - allowPrerelease: false, - autoDownload: true, - autoInstallOnAppQuit: true, - channel: "latest", - disableDifferentialDownload: false, - fullChangelog: false, - checkForUpdates: vi.fn(() => Promise.resolve(null)), - downloadUpdate: vi.fn(() => Promise.resolve([])), - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - verifyUpdateCodeSignature: undefined as VerifyUpdateCodeSignature | undefined, - on: vi.fn(), - quitAndInstall: vi.fn(), - removeListener: vi.fn(), - setFeedURL: vi.fn(), - }, -})); - -vi.mock("electron-updater", () => ({ - get autoUpdater() { - return updaterOverride.current ?? autoUpdaterMock; - }, -})); - -import * as ElectronUpdater from "./ElectronUpdater.ts"; - -describe("ElectronUpdater", () => { - beforeEach(() => { - autoUpdaterMock.allowDowngrade = false; - autoUpdaterMock.allowPrerelease = false; - autoUpdaterMock.autoDownload = true; - autoUpdaterMock.autoInstallOnAppQuit = true; - autoUpdaterMock.channel = "latest"; - autoUpdaterMock.disableDifferentialDownload = false; - autoUpdaterMock.fullChangelog = false; - autoUpdaterMock.checkForUpdates.mockClear(); - autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); - autoUpdaterMock.downloadUpdate.mockClear(); - autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); - autoUpdaterMock.on.mockClear(); - autoUpdaterMock.quitAndInstall.mockClear(); - autoUpdaterMock.removeListener.mockClear(); - autoUpdaterMock.setFeedURL.mockClear(); - autoUpdaterMock.verifyUpdateCodeSignature = undefined; - }); - - it.effect("scopes updater event listeners", () => - Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - yield* updater.on("update-available", listener); - }), - ); - - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); - assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("wraps rejected update checks in the method-specific typed error", () => - Effect.gen(function* () { - const cause = new Error("network unavailable"); - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "beta"; - - const error = yield* updater.checkForUpdates.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.channel, "beta"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves the execution-time channel on download failures", () => - Effect.gen(function* () { - const cause = new Error("download unavailable"); - autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "nightly"; - - const error = yield* updater.downloadUpdate.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - "Electron updater failed to download the update on channel nightly.", - ); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("sets full changelog mode", () => - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - - yield* updater.setFullChangelog(true); - assert.equal(autoUpdaterMock.fullChangelog, true); - - yield* updater.setFullChangelog(false); - assert.equal(autoUpdaterMock.fullChangelog, false); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves quit-and-install flags and the execution-time channel", () => - Effect.gen(function* () { - const cause = new Error("quit and install failed"); - autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { - throw cause; - }); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "alpha"; - - const error = yield* updater - .quitAndInstall({ isSilent: true, isForceRunAfter: false }) - .pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.equal(error.channel, "alpha"); - assert.equal(error.isSilent, true); - assert.equal(error.isForceRunAfter, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", - ); - assert.notInclude(error.message, cause.message); - assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - describe("Windows update signature check", () => { - const publisherNames = ["T3 Tools Inc"]; - const installerPath = "C:\\updater\\pending\\temp-T3-Code-x64.exe"; - // execFile marks the error killed when the verifier exceeds its 20-second timeout. - const timedOut = () => - Object.assign(new Error("Command failed: powershell.exe"), { killed: true }); - - const downloadVerifyingSignature = () => { - autoUpdaterMock.downloadUpdate.mockImplementationOnce(async () => { - const rejection = await autoUpdaterMock.verifyUpdateCodeSignature!( - publisherNames, - installerPath, - ); - // NSIS accepts only null as a successful signature check. - if (rejection !== null) throw new Error(`Invalid signature: ${rejection}`); - return []; - }); - return Effect.flatMap(ElectronUpdater.ElectronUpdater, (updater) => updater.downloadUpdate); - }; - - it.effect("retries a timed-out signature check with the same verifier and installer", () => - Effect.gen(function* () { - const verify = vi - .fn() - .mockRejectedValueOnce(timedOut()) - .mockResolvedValueOnce(null); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - yield* downloadVerifyingSignature(); - - assert.deepEqual(verify.mock.calls, [ - [publisherNames, installerPath], - [publisherNames, installerPath], - ]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("fails on invalid signatures, other errors and two timeouts on each download", () => - Effect.gen(function* () { - const cases = [ - { outcome: () => Promise.resolve("Invalid publisher"), calls: 1 }, - { outcome: () => Promise.reject(new Error("Cannot execute PowerShell")), calls: 1 }, - { outcome: () => Promise.reject(timedOut()), calls: 2 }, - ]; - for (const { outcome, calls } of cases) { - const verify = vi.fn(outcome); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - // Reusing the installed verifier must neither stack retries nor exhaust future retries. - for (let download = 1; download <= 2; download++) { - const error = yield* downloadVerifyingSignature().pipe(Effect.flip); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(verify.mock.calls.length, calls * download); - } - } - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("rejects a retry when the upstream capability probe skips verification", () => - Effect.gen(function* () { - const verify = vi - .fn() - .mockRejectedValueOnce(timedOut()) - .mockImplementationOnce(async () => { - // Upstream resolves null even though its failed capability probe skips verification. - autoUpdaterMock.logger.warn( - "Cannot execute ConvertTo-Json: spawnSync powershell.exe ETIMEDOUT. Ignoring signature validation due to unsupported powershell version. Please upgrade to powershell 3 or higher.", - ); - return null; - }); - autoUpdaterMock.verifyUpdateCodeSignature = verify; - - const error = yield* downloadVerifyingSignature().pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.equal(verify.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - }); -}); - -describe("installed electron-updater cache recovery", () => { - it.effect.each([ - { - name: "Intel Mac", - platform: "darwin", - Updater: MacUpdater, - selected: "x64.zip", - other: "arm64.zip", - rosetta: false, - }, - { - name: "Apple Silicon running Intel", - platform: "darwin", - Updater: MacUpdater, - selected: "arm64.zip", - other: "x64.zip", - rosetta: true, - }, - { - name: "Windows", - platform: "win32", - Updater: NsisUpdater, - selected: "x64.exe", - other: "arm64.exe", - rosetta: false, - }, - { - name: "Debian", - platform: "linux", - Updater: DebUpdater, - selected: "x64.deb", - other: "x64.AppImage", - rosetta: false, - }, - { - name: "AppImage", - platform: "linux", - Updater: AppImageUpdater, - selected: "x64.AppImage", - other: "x64.deb", - rosetta: false, - }, - ] as const)("restores only the package selected by $name", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped(); - const cacheDirectory = NodePath.join(directory, "custom-updater-cache", "pending"); - const configPath = NodePath.join(directory, "app-update.yml"); - yield* fs.writeFileString(configPath, "updaterCacheDirName: custom-updater-cache\n"); - yield* fs.makeDirectory(cacheDirectory, { recursive: true }); - - const require = NodeModule.createRequire(import.meta.url); - require("electron"); - const electronModule = require.cache[require.resolve("electron")]!; - const childProcess = require("node:child_process") as typeof import("node:child_process"); - const originalElectron = electronModule.exports; - const originalArch = Object.getOwnPropertyDescriptor(process, "arch")!; - yield* Effect.acquireRelease( - Effect.sync(() => { - // MacUpdater's native Squirrel bridge and host probes are OS boundaries. - electronModule.exports = { autoUpdater: new NodeEvents.EventEmitter() }; - Object.defineProperty(process, "arch", { value: "x64", configurable: true }); - return vi - .spyOn(childProcess, "execFileSync") - .mockReturnValue(scenario.rosetta ? "sysctl.proc_translated: 1" : "x86_64"); - }), - (probe) => - Effect.sync(() => { - probe.mockRestore(); - Object.defineProperty(process, "arch", originalArch); - electronModule.exports = originalElectron; - updaterOverride.current = undefined; - }), - ); - - const extensions = - scenario.platform === "darwin" - ? ["arm64.dmg", "x64.dmg", "arm64.zip", "x64.zip"] - : scenario.platform === "win32" - ? ["arm64.exe", "x64.exe"] - : ["arm64.AppImage", "x64.AppImage", "arm64.deb", "x64.deb"]; - const files = extensions.map((extension) => { - const url = `T3-Code-1.2.4-${extension}`; - return { url, sha512: NodeCrypto.createHash("sha512").update(url).digest("base64") }; - }); - const info = { - version: "1.2.4", - releaseDate: "2026-10-01T00:00:00Z", - files, - path: files[0]!.url, - sha512: files[0]!.sha512, - }; - yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(GenericProvider.prototype, "getLatestVersion").mockResolvedValue(info), - ), - (feed) => Effect.sync(() => feed.mockRestore()), - ); - - // A fresh instance for each launch forces disk validation rather than in-memory reuse. - yield* Effect.forEach([scenario.other, scenario.selected], (cachedExtension) => - Effect.scoped( - Effect.gen(function* () { - const cached = files.find((file) => file.url.endsWith(`-${cachedExtension}`))!; - yield* fs.writeFileString(NodePath.join(cacheDirectory, cached.url), cached.url); - yield* fs.writeFileString( - NodePath.join(cacheDirectory, "update-info.json"), - JSON.stringify({ - fileName: cached.url, - sha512: cached.sha512, - isAdminRightsRequired: false, - }), - ); - const updater = new scenario.Updater(undefined, { - version: "1.2.3", - name: "T3 Code", - isPackaged: true, - appUpdateConfigPath: configPath, - userDataPath: directory, - baseCachePath: directory, - whenReady: () => Promise.resolve(), - relaunch: vi.fn(), - quit: vi.fn(), - onQuit: vi.fn(), - }); - updater.logger = null; - const networkDownload = vi.fn(() => - Promise.reject(new Error("unexpected network download")), - ); - Object.assign(updater, { httpExecutor: { download: networkDownload } }); - if (updater instanceof MacUpdater) { - // Keep real selection, executeDownload, and hash validation; stop at native handoff. - Object.assign(updater, { - updateDownloaded: async (_file: unknown, event: UpdateDownloadedEvent) => { - updater.emit("update-downloaded", event); - return []; - }, - }); - } - updaterOverride.current = updater; - assert.deepEqual( - yield* ElectronUpdater.make.pendingDownloadDirectory, - Option.some(cacheDirectory), - ); - - const inspected = Deferred.makeUnsafe(); - const downloadFinished = Deferred.makeUnsafe(); - const download = vi.spyOn(updater, "downloadUpdate"); - const harness = makeHarness({ - platform: scenario.platform, - runningUnderArm64Translation: scenario.rosetta, - env: scenario.selected.endsWith("AppImage") - ? { APPIMAGE: "/test/T3-Code.AppImage" } - : {}, - updater: { - ...ElectronUpdater.make, - downloadUpdate: ElectronUpdater.make.downloadUpdate.pipe( - Effect.ensuring(Deferred.succeed(downloadFinished, undefined)), - ), - }, - fileSystem: { - ...fs, - readFileString: (path, encoding) => - path === "/missing/resources/package-type" && scenario.selected.endsWith("deb") - ? Effect.succeed("deb") - : fs.readFileString(path, encoding), - exists: (path) => - fs.exists(path).pipe(Effect.tap(() => Deferred.succeed(inspected, undefined))), - }, - }); - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - // AppImage activation reads the host environment; the fixture is intentionally not installed. - vi.spyOn(updater, "isUpdaterActive").mockReturnValue(true); - assert.isTrue((yield* updates.check("startup")).checked); - yield* Deferred.await(inspected); - yield* flushCallbacks; - - if (cachedExtension === scenario.other) { - assert.equal(download.mock.calls.length, 0); - assert.equal((yield* updates.getState).status, "available"); - } else { - yield* Deferred.await(downloadFinished); - yield* flushCallbacks; - assert.equal(download.mock.calls.length, 1); - assert.equal((yield* updates.getState).status, "downloaded"); - assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); - } - assert.equal(networkDownload.mock.calls.length, 0); - }), - ).pipe(Effect.provide(harness.layer)); - }), - ), - ); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 77709c4a2f35..e42ebc1003ab 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,12 +1,10 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; -import type { NsisUpdater, VerifyUpdateCodeSignature } from "electron-updater"; type AutoUpdater = typeof autoUpdater; @@ -69,7 +67,6 @@ export class ElectronUpdater extends Context.Service< readonly setAllowDowngrade: (value: boolean) => Effect.Effect; readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly pendingDownloadDirectory: Effect.Effect>; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; readonly quitAndInstall: (options: { @@ -83,45 +80,6 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -const signatureChecksWithRetry = new WeakSet(); - -const retrySignatureCheckAfterTimeout = () => { - // Only the Windows NSIS updater exposes this hook; its PowerShell check times out after 20 s. - const updater = autoUpdater as AutoUpdater & - Partial>; - const verify = updater.verifyUpdateCodeSignature; - if (verify === undefined || signatureChecksWithRetry.has(verify)) return; - - const verifyWithRetry: VerifyUpdateCodeSignature = async (publisherNames, path) => { - try { - return await verify(publisherNames, path); - } catch (error) { - if (!(error instanceof Error && "killed" in error && error.killed === true)) throw error; - } - - const logger = updater.logger; - let skipped = false; - updater.logger = { - info: (message) => logger?.info(message), - warn: (message) => { - // A failed capability probe makes electron-updater warn and return null without checking. - skipped ||= String(message).includes("Ignoring signature validation"); - logger?.warn(message); - }, - error: (message) => logger?.error(message), - debug: (message) => logger?.debug?.(message), - }; - try { - const result = await verify(publisherNames, path); - return skipped ? "Signature validation was skipped after a timed-out check." : result; - } finally { - updater.logger = logger; - } - }; - signatureChecksWithRetry.add(verifyWithRetry); - updater.verifyUpdateCodeSignature = verifyWithRetry; -}; - /** @public Service construction is part of the canonical Effect module API. */ export const make = ElectronUpdater.of({ setFeedURL: (options) => @@ -165,17 +123,6 @@ export const make = ElectronUpdater.of({ autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - // Use the updater's helper so cache lookup follows the same configuration as downloads. - pendingDownloadDirectory: Effect.tryPromise(() => - ( - autoUpdater as unknown as { - getOrCreateDownloadHelper: () => Promise<{ readonly cacheDirForPendingUpdate: string }>; - } - ).getOrCreateDownloadHelper(), - ).pipe( - Effect.map((helper) => helper.cacheDirForPendingUpdate), - Effect.option, - ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -184,7 +131,6 @@ export const make = ElectronUpdater.of({ }).pipe(Effect.asVoid); }), downloadUpdate: Effect.suspend(() => { - retrySignatureCheckAfterTimeout(); const channel = autoUpdater.channel; return Effect.tryPromise({ try: () => autoUpdater.downloadUpdate(), diff --git a/apps/desktop/src/electron/ElectronWindow.module.test.ts b/apps/desktop/src/electron/ElectronWindow.module.test.ts deleted file mode 100644 index f272f84b5d82..000000000000 --- a/apps/desktop/src/electron/ElectronWindow.module.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -vi.mock("@crowecawcaw/xa11y", () => { - throw new Error("Cannot find native binding"); -}); - -it("loads without an accessibility native binding", async () => { - await expect(import("./ElectronWindow.ts")).resolves.toBeDefined(); -}); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index ef94312ed6f0..4f17ca7e3301 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -62,13 +62,6 @@ const layerTestFor = (platform: NodeJS.Platform) => const layerTest = layerTestFor("linux"); -function makeBrowserWindow(input: { readonly id: number; readonly destroyed: boolean }) { - return { - id: input.id, - isDestroyed: vi.fn(() => input.destroyed), - } as unknown as Electron.BrowserWindow; -} - function makeWindowsRevealWindow() { return { id: 41, @@ -109,341 +102,6 @@ describe("ElectronWindow", () => { windowsForegroundCloseMock.mockReset(); }); - it.effect("preserves schema-safe creation context and the Electron cause", () => - Effect.gen(function* () { - const cause = new Error("native BrowserWindow construction failed"); - browserWindowMock.mockImplementationOnce(function BrowserWindowFailure() { - throw cause; - }); - const options = { - title: "T3 Code", - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - modal: false, - frame: true, - transparent: false, - backgroundColor: "#101010", - icon: {} as Electron.NativeImage, - webPreferences: { - preload: "/tmp/preload.js", - partition: "persist:t3code-preview-test", - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - webviewTag: true, - spellcheck: true, - }, - } satisfies Electron.BrowserWindowConstructorOptions; - const electronWindow = yield* ElectronWindow.ElectronWindow; - - const error = yield* electronWindow.create(options).pipe(Effect.flip); - - assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.deepEqual(error.options, { - title: "T3 Code", - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - modal: false, - frame: true, - transparent: false, - backgroundColor: "#101010", - webPreferences: { - preload: "/tmp/preload.js", - partition: "persist:t3code-preview-test", - backgroundThrottling: null, - sandbox: true, - contextIsolation: true, - nodeIntegration: false, - webviewTag: true, - }, - }); - assert.isFalse("icon" in error.options); - assert.isFalse("spellcheck" in error.options.webPreferences); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, 'Failed to create Electron BrowserWindow "T3 Code" (1100x780).'); - assert.notInclude(error.message, cause.message); - assert.deepEqual(browserWindowMock.mock.calls, [[options]]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("skips windows destroyed before appearance sync runs", () => - Effect.gen(function* () { - const liveWindow = makeBrowserWindow({ id: 1, destroyed: false }); - const destroyedWindow = makeBrowserWindow({ id: 2, destroyed: true }); - getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); - - const syncedWindows: Electron.BrowserWindow[] = []; - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.syncAllAppearance((window) => - Effect.sync(() => { - syncedWindows.push(window); - }), - ); - - assert.deepEqual(syncedWindows, [liveWindow]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves window enumeration failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("window enumeration failed"); - getAllWindowsMock.mockImplementationOnce(() => { - throw cause; - }); - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.currentMainOrFirst); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "list-windows"); - assert.equal(error.platform, "linux"); - assert.isNull(error.windowId); - assert.isNull(error.channel); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves reveal failures with the target window", () => - Effect.gen(function* () { - const cause = new Error("window restore failed"); - const window = { - id: 41, - isDestroyed: vi.fn(() => false), - isMinimized: vi.fn(() => true), - restore: vi.fn(() => { - throw cause; - }), - } as unknown as Electron.BrowserWindow; - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.reveal(window)); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "reveal-window"); - assert.equal(error.windowId, 41); - assert.isNull(error.channel); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("uses native Windows activation without querying the foreground window", () => - Effect.gen(function* () { - const operations: Array = []; - appFocusMock.mockImplementation(() => operations.push("app-focus")); - activateWindowsForegroundMock.mockImplementation(async () => { - operations.push("native-activation"); - }); - const window = { - ...makeWindowsRevealWindow(), - show: vi.fn(() => operations.push("show")), - moveTop: vi.fn(() => operations.push("move-top")), - focus: vi.fn(() => operations.push("focus")), - } as unknown as Electron.BrowserWindow; - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window); - - yield* electronWindow.reveal(window); - - assert.deepEqual(operations, ["app-focus", "show", "move-top", "focus", "native-activation"]); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("focuses the exact T3 window before activating from a shell-hosted app", () => - Effect.gen(function* () { - const operations: Array = []; - shellHostedForegroundMock.mockResolvedValue(true); - windowsForegroundFocusMock.mockImplementation(async () => { - operations.push("native-focus"); - return true; - }); - activateWindowsForegroundMock.mockImplementation(async () => { - operations.push("native-activation"); - }); - const window = { - ...makeWindowsRevealWindow(), - show: vi.fn(() => operations.push("show")), - moveTop: vi.fn(() => operations.push("move-top")), - focus: vi.fn(() => operations.push("focus")), - } as unknown as Electron.BrowserWindow; - appFocusMock.mockImplementation(() => operations.push("app-focus")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window); - - yield* electronWindow.reveal(window); - - assert.deepEqual(operations, [ - "app-focus", - "show", - "move-top", - "focus", - "native-focus", - "native-activation", - ]); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 1); - assert.deepEqual(windowsForegroundFocusMock.mock.calls, [ - [ - { - windowId: 41, - processId: process.pid, - title: "T3 Code (Dev)", - bounds: { x: 100, y: 50, width: 1_200, height: 800 }, - contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, - }, - ], - ]); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("prepares the exact T3 window before a capture overlay", () => - Effect.gen(function* () { - windowsForegroundPrepareMock.mockResolvedValue(true); - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - const prepared = yield* electronWindow.prepareReveal( - window as unknown as Electron.BrowserWindow, - ); - - assert.isTrue(prepared); - assert.deepEqual(windowsForegroundPrepareMock.mock.calls, [ - [ - { - windowId: 41, - processId: process.pid, - title: "T3 Code (Dev)", - bounds: { x: 100, y: 50, width: 1_200, height: 800 }, - contentBounds: { x: 108, y: 50, width: 1_184, height: 792 }, - }, - ], - ]); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect.each([4, 8])( - "skips the focus worker only when the foreground matches the %i-byte HWND", - (handleBytes) => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - const hwnd = handleBytes === 4 ? 0xf123_4567n : 0x1_f123_4567n; - const handle = Buffer.alloc(handleBytes); - if (handleBytes === 4) handle.writeUInt32LE(Number(hwnd)); - else handle.writeBigUInt64LE(hwnd); - window.getNativeWindowHandle.mockReturnValue(handle); - foregroundWindowMock.mockReturnValue(hwnd); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - window.getTitle.mockClear(); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(foregroundWindowMock.mock.calls, 1); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(window.getTitle.mock.calls, 0); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect.each([42n, 0n])( - "focuses through the worker when the foreground HWND is %s instead of the target", - (foreground) => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - foregroundWindowMock.mockReturnValue(foreground); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("continues native focus when the foreground query fails", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - loadWindowsForegroundApiMock.mockRejectedValue(new Error("Foreground query unavailable")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("does not fail reveal when native focus rejects", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValueOnce( - new Error("Windows initially refused foreground activation"), - ); - windowsForegroundFocusMock.mockRejectedValue(new Error("Focus rejected")); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 1); - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("fails reveal when Windows refuses foreground activation", () => - Effect.gen(function* () { - const cause = new Error("Windows refused foreground activation"); - const window = makeWindowsRevealWindow(); - activateWindowsForegroundMock.mockRejectedValue(cause); - const electronWindow = yield* ElectronWindow.ElectronWindow; - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - - const exit = yield* Effect.exit( - electronWindow.reveal(window as unknown as Electron.BrowserWindow), - ); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "reveal-window"); - assert.strictEqual(error.cause, cause); - } - assert.deepEqual(activateWindowsForegroundMock.mock.calls, [ - [window.getNativeWindowHandle.mock.results[0]?.value], - [window.getNativeWindowHandle.mock.results[1]?.value], - ]); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - it.effect("cancels native focus when destroyed during the foreground query", () => Effect.gen(function* () { const window = makeWindowsRevealWindow(); @@ -474,35 +132,6 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); - it.effect("preserves message delivery failures with window and channel context", () => - Effect.gen(function* () { - const cause = new Error("renderer send failed"); - const window = { - id: 42, - isDestroyed: vi.fn(() => false), - webContents: { - send: vi.fn(() => { - throw cause; - }), - }, - } as unknown as Electron.BrowserWindow; - getAllWindowsMock.mockReturnValueOnce([window]); - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const exit = yield* Effect.exit(electronWindow.sendAll("desktop:update", { ready: true })); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronWindow.ElectronWindowOperationError); - assert.equal(error.operation, "send-window-message"); - assert.equal(error.windowId, 42); - assert.equal(error.channel, "desktop:update"); - assert.strictEqual(error.cause, cause); - } - }).pipe(Effect.provide(layerTest)), - ); - it.effect("preserves destroy failures and continues with later windows", () => Effect.gen(function* () { const cause = new Error("window destroy failed"); @@ -533,50 +162,4 @@ describe("ElectronWindow", () => { assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(layerTest)), ); - it.effect("an ordinary reveal on Windows does not touch the Win32 foreground helpers", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 0); - assert.lengthOf(windowsForegroundFocusMock.mock.calls, 0); - assert.lengthOf(shellHostedForegroundMock.mock.calls, 0); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 0); - assert.lengthOf(window.focus.mock.calls, 1); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("a capture reveal on Windows uses the Win32 path only once", () => - Effect.gen(function* () { - const window = makeWindowsRevealWindow(); - const electronWindow = yield* ElectronWindow.ElectronWindow; - - yield* electronWindow.prepareReveal(window as unknown as Electron.BrowserWindow); - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - yield* electronWindow.reveal(window as unknown as Electron.BrowserWindow); - - assert.lengthOf(activateWindowsForegroundMock.mock.calls, 1); - assert.lengthOf(window.focus.mock.calls, 2); - }).pipe(Effect.provide(layerTestFor("win32"))), - ); - - it.effect("starts the Windows focus worker lazily and closes it with the layer", () => - Effect.gen(function* () { - yield* ElectronWindow.ElectronWindow.pipe(Effect.provide(layerTestFor("win32"))); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 0); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 0); - - yield* Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; - const window = makeWindowsRevealWindow() as unknown as Electron.BrowserWindow; - yield* electronWindow.prepareReveal(window); - yield* electronWindow.prepareReveal(window); - assert.lengthOf(startWindowsForegroundFocusThreadMock.mock.calls, 1); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 0); - }).pipe(Effect.provide(layerTestFor("win32"))); - assert.lengthOf(windowsForegroundCloseMock.mock.calls, 1); - }), - ); }); diff --git a/apps/desktop/src/electron/WindowsForeground.test.ts b/apps/desktop/src/electron/WindowsForeground.test.ts index 3ac4b408f376..60975495976c 100644 --- a/apps/desktop/src/electron/WindowsForeground.test.ts +++ b/apps/desktop/src/electron/WindowsForeground.test.ts @@ -3,7 +3,6 @@ import { vi } from "vite-plus/test"; import { activateWindowsForegroundWithApi, - isWindowsShellHostedForegroundWithApi, type WindowsForegroundApi, } from "./WindowsForeground.ts"; @@ -55,66 +54,10 @@ describe("Windows foreground activation", () => { assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); }); - it("does not disturb input queues when T3 is already foreground", () => { - const api = makeApi({ foregroundWindow: 41n }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.lengthOf(api.getCurrentThreadId.mock.calls, 0); - assert.lengthOf(api.attachThreadInput.mock.calls, 0); - assert.lengthOf(api.setForegroundWindow.mock.calls, 0); - }); - - it("does not attach a thread to itself", () => { - const api = makeApi({ currentThreadId: 20, foregroundThreadId: 20 }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.lengthOf(api.attachThreadInput.mock.calls, 0); - assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); - }); - - it("uses SetForegroundWindow's result as the activation receipt", () => { - const api = makeApi({ activated: false }); - - assert.isFalse(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.deepEqual(api.attachThreadInput.mock.calls, [ - [10, 20, true], - [10, 20, false], - ]); - }); - - it("still asks Windows directly when the input queues cannot be attached", () => { - const api = makeApi({ attached: false }); - - assert.isTrue(activateWindowsForegroundWithApi(nativeHandle(41n), api)); - - assert.deepEqual(api.attachThreadInput.mock.calls, [[10, 20, true]]); - assert.deepEqual(api.setForegroundWindow.mock.calls, [[41n]]); - }); - it("rejects malformed native handles", () => { const api = makeApi({}); assert.throws(() => activateWindowsForegroundWithApi(Buffer.alloc(6), api)); assert.lengthOf(api.getForegroundWindow.mock.calls, 0); }); - - it("recognizes a shell-hosted foreground window", () => { - const api = makeApi({}); - api.getWindowClassName.mockReturnValue("ApplicationFrameWindow"); - - assert.isTrue(isWindowsShellHostedForegroundWithApi(api)); - assert.deepEqual(api.getWindowClassName.mock.calls, [[99n]]); - }); - - it("does not classify ordinary or missing foreground windows as shell-hosted", () => { - const ordinary = makeApi({}); - const missing = makeApi({ foregroundWindow: 0n }); - - assert.isFalse(isWindowsShellHostedForegroundWithApi(ordinary)); - assert.isFalse(isWindowsShellHostedForegroundWithApi(missing)); - assert.lengthOf(missing.getWindowClassName.mock.calls, 0); - }); }); diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index 47ecd7fd71f8..a124697e15bf 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -54,7 +54,7 @@ export function activateWindowsForegroundWithApi( } } -export function isWindowsShellHostedForegroundWithApi(api: WindowsForegroundApi): boolean { +function isWindowsShellHostedForegroundWithApi(api: WindowsForegroundApi): boolean { const foregroundWindow = api.getForegroundWindow(); return ( foregroundWindow !== 0n && diff --git a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts index 46193ca2c341..ac15b145e0c6 100644 --- a/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts +++ b/apps/desktop/src/electron/WindowsForegroundFocusThread.test.ts @@ -68,33 +68,3 @@ it("fails pending focus when the helper exits", async () => { assert.lengthOf(workerConstructorMock.mock.calls, 2); thread.close(); }); - -it("replaces a timed-out helper before accepting more work", async () => { - vi.useFakeTimers(); - const replacement = makeWorker(); - workerConstructorMock.mockReturnValueOnce(worker).mockReturnValueOnce(replacement); - const thread = startWindowsForegroundFocusThread("focus.cjs"); - - try { - worker.emit("message", "ready"); - const focused = thread.focus(target); - await vi.advanceTimersByTimeAsync(1_000); - - assert.isFalse(await focused); - assert.lengthOf(worker.terminate.mock.calls, 1); - assert.lengthOf(workerConstructorMock.mock.calls, 2); - - replacement.emit("message", "ready"); - const prepared = thread.prepare(target); - assert.deepEqual(replacement.postMessage.mock.calls[0]?.[0], { - type: "prepare", - requestId: 2, - target, - }); - replacement.emit("message", { type: "result", requestId: 2, focused: true }); - assert.isTrue(await prepared); - } finally { - thread.close(); - vi.useRealTimers(); - } -}); diff --git a/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts b/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts deleted file mode 100644 index 4c07e4671fb2..000000000000 --- a/apps/desktop/src/electron/WindowsForegroundFocusWorker.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; - -const { byPid, list, on, postMessage } = vi.hoisted(() => ({ - byPid: vi.fn(), - list: vi.fn(), - on: vi.fn(), - postMessage: vi.fn(), -})); - -vi.mock("node:worker_threads", () => ({ parentPort: { on, postMessage } })); -vi.mock("@crowecawcaw/xa11y", () => ({ App: { byPid, list } })); - -beforeEach(() => { - vi.resetModules(); - vi.resetAllMocks(); -}); - -it.each(["lookup", "children"])("falls back to enumeration after a failed %s", async (failure) => { - const error = new Error("Unavailable"); - if (failure === "lookup") byPid.mockRejectedValue(error); - else byPid.mockResolvedValue({ children: () => Promise.reject(error) }); - const bounds = { x: 0, y: 0, width: 800, height: 600 }; - const focus = vi.fn().mockResolvedValue(undefined); - list.mockResolvedValue([{ pid: 42, asElement: () => ({ name: "T3", bounds, focus }) }]); - await import("./WindowsForegroundFocusWorker.ts"); - await vi.dynamicImportSettled(); - const result = Promise.withResolvers(); - postMessage.mockImplementation(result.resolve); - on.mock.calls[0]![1]({ - type: "focus", - requestId: 1, - target: { windowId: 1, processId: 42, title: "T3", bounds, contentBounds: bounds }, - }); - assert.deepEqual(await result.promise, { type: "result", requestId: 1, focused: true }); - assert.lengthOf(focus.mock.calls, 1); -}); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts deleted file mode 100644 index e90491f1f4e7..000000000000 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { vi } from "vite-plus/test"; - -import * as DesktopIpc from "./DesktopIpc.ts"; - -const invokeMethod: DesktopIpc.DesktopIpcMethod = { - channel: "desktop.test.invoke", - handler: () => Effect.void, -}; - -const syncMethod: DesktopIpc.DesktopSyncIpcMethod = { - channel: "desktop.test.sync", - handler: () => Effect.void, -}; - -function makeIpcMain( - overrides: Partial = {}, -): DesktopIpc.DesktopIpcMain { - return { - removeHandler: vi.fn(), - handle: vi.fn(), - removeAllListeners: vi.fn(), - on: vi.fn(), - ...overrides, - }; -} - -describe("DesktopIpc", () => { - it.effect("preserves invoke registration context and cause", () => - Effect.gen(function* () { - const cause = new Error("invoke registration failed"); - const ipcMain = makeIpcMain({ - handle: () => { - throw cause; - }, - }); - const ipc = DesktopIpc.make(ipcMain); - - const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); - - assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.strictEqual(error.handlerKind, "invoke"); - assert.strictEqual(error.channel, invokeMethod.channel); - assert.strictEqual(error.cause, cause); - assert.include(error.message, "invoke"); - assert.include(error.message, invokeMethod.channel); - assert.notInclude(error.message, cause.message); - }), - ); - - it.effect("forwards the invoke sender to the method", () => - Effect.gen(function* () { - let listener: DesktopIpc.DesktopIpcHandleListener | undefined; - const ipc = DesktopIpc.make( - makeIpcMain({ - handle: (_channel, registered) => { - listener = registered; - }, - }), - ); - const sender = { sender: { id: 7 } }; - let received: DesktopIpc.DesktopIpcInvokeEvent | undefined; - - yield* Effect.scoped( - Effect.gen(function* () { - yield* ipc.handle({ - channel: "desktop.test.sender", - handler: (_raw, event) => - Effect.sync(() => { - received = event; - }), - }); - yield* Effect.promise(async () => listener!(sender, undefined)); - }), - ); - - assert.strictEqual(received, sender); - }), - ); - - it.effect("preserves sync unregistration context and cause in the finalizer defect", () => - Effect.gen(function* () { - const cause = new Error("sync unregistration failed"); - let removeCount = 0; - const ipcMain = makeIpcMain({ - removeAllListeners: () => { - removeCount += 1; - if (removeCount === 2) throw cause; - }, - }); - const ipc = DesktopIpc.make(ipcMain); - - const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod))); - - assert.isTrue(exit._tag === "Failure"); - if (exit._tag === "Success") return; - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.strictEqual(error.handlerKind, "sync"); - assert.strictEqual(error.channel, syncMethod.channel); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - }), - ); -}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 1c278be29937..e99b73cc08c3 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,7 +40,6 @@ import { import { getAppBranding, getLocalEnvironmentBootstraps, - readLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getRelayTelemetryEnabled, getSystemLocale, @@ -104,7 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); - yield* ipc.handle(readLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index 40d1df6a3366..7126b1cd3194 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -28,7 +28,7 @@ vi.mock("electron", () => ({ import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; -import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; +import { installNotificationBadge } from "./notificationBadge.ts"; const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; @@ -42,51 +42,6 @@ beforeEach(() => { native.listeners.clear(); }); -it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { - applyNotificationBadge(platform, badge); - applyNotificationBadge(platform, { count: 0, image: null }); - expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); - expect(native.createFromDataURL).not.toHaveBeenCalled(); -}); - -it("sets and clears the Windows taskbar overlay", () => { - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon).toHaveBeenLastCalledWith( - native.image, - "2 threads with new notifications", - ); - applyNotificationBadge("win32", { count: 0, image: null }); - expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); -}); - -it.each(["win32", "darwin", "linux"] as const)( - "rejects a late positive count while %s is focused", - (platform) => { - native.getFocusedWindow.mockReturnValue({}); - applyNotificationBadge(platform, badge); - if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); - else expect(native.setBadgeCount).toHaveBeenCalledWith(0); - expect(native.createFromDataURL).not.toHaveBeenCalled(); - }, -); - -it("ignores destroyed windows and clears invalid images", () => { - native.isDestroyed.mockReturnValue(true); - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon).not.toHaveBeenCalled(); - native.isDestroyed.mockReturnValue(false); - native.image.isEmpty.mockReturnValue(true); - applyNotificationBadge("win32", badge); - expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); -}); - -it("keeps notifications working when the native badge API fails", () => { - native.setBadgeCount.mockImplementation(() => { - throw new Error("Unavailable"); - }); - expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); -}); - it.effect("validates IPC and clears on native focus, quit, and disposal", () => Effect.gen(function* () { const handlers = new Map(); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts index 952745b4b7c6..39eeeeeb1f30 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -17,7 +17,7 @@ const NotificationBadge = Schema.Struct({ ), }); -export function applyNotificationBadge( +function applyNotificationBadge( platform: NodeJS.Platform, { count, image }: typeof NotificationBadge.Type, ): void { diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 32460966ab13..802f2f3fb18b 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -2,18 +2,14 @@ import { it as effectIt } from "@effect/vitest"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID, - PreviewAutomationStatus, PreviewCaptureFailure, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as PreviewManager from "../../preview/Manager.ts"; -import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewIpc from "./preview.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; @@ -111,36 +107,10 @@ describe("preview IPC methods", () => { }), ); - effectIt.effect("preserves recording arm failures without erasing successful void results", () => - Effect.gen(function* () { - const result = yield* invokeCapture(PreviewIpc.startRecording, { - startRecording: (tabId) => - Effect.fail(new PreviewManager.PreviewTabNotFoundError({ tabId })), - }); - expect(result).toEqual({ - _tag: "PreviewCaptureFailure", - reason: "renderer-unavailable", - stage: "recording-start", - }); - const failure = yield* decodeCaptureFailure(result); - expect(failure.message).toContain("Reopen the preview tab"); - expect( - yield* invokeCapture(PreviewIpc.startRecording, { - startRecording: () => Effect.void, - }), - ).toBeUndefined(); - }), - ); - beforeEach(() => { fromPartition.mockClear(); }); - it("does not access the Electron session while the module loads", async () => { - await expect(import("./preview.ts")).resolves.toBeDefined(); - expect(fromPartition).not.toHaveBeenCalled(); - }); - it("derives distinct partition scopes when identifiers contain the delimiter", () => { const first = PreviewIpc.resolvePartitionScope("a", "b::c"); const second = PreviewIpc.resolvePartitionScope("a::b", "c"); @@ -150,18 +120,6 @@ describe("preview IPC methods", () => { expect(first.scope).not.toBe(second.scope); }); - it("preserves lone surrogates without collapsing them to replacement characters", () => { - const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800"); - const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00"); - const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�"); - - expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]'); - expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]'); - expect(highSurrogate.scope).not.toBe(lowSurrogate.scope); - expect(highSurrogate.scope).not.toBe(replacement.scope); - expect(lowSurrogate.scope).not.toBe(replacement.scope); - }); - it("keeps the legacy default partition scope and incognito persistence", () => { expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({ scope: "environment::legacy", @@ -178,67 +136,4 @@ describe("preview IPC methods", () => { namespace: "profile", }); }); - - effectIt.effect("targets imports at the same partition tuple as the renderer", () => { - const received: Array[0]> = - []; - const browserImport = BrowserImport.BrowserImport.of({ - listSources: Effect.succeed([]), - importCookies: (input) => - Effect.sync(() => { - received.push(input); - return { imported: 0, skipped: 0, skippedDomains: [] }; - }), - }); - const request = (environmentId: string, targetProfileId: string) => - PreviewIpc.importBrowserCookies.handler({ - environmentId, - sourceId: "helium", - sourceProfileDirectory: "Default", - targetProfileId, - }); - - return Effect.gen(function* () { - yield* request("a", "b"); - yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID); - - expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b")); - expect(received[1]).toMatchObject( - PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID), - ); - expect(received[0]?.namespace).toBe("profile"); - expect(received[1]?.namespace).toBeUndefined(); - }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport)); - }); - - effectIt.effect("rejects invalid webContents ids before resolving the preview service", () => - Effect.map( - PreviewIpc.registerWebview - .handler({ tabId: "tab-1", webContentsId: 0 }) - .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit), - (exit) => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true); - expect(fromPartition).not.toHaveBeenCalled(); - }, - ), - ); - - it("keeps the public automation status tab id limit", () => { - const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); - const tabId = "t".repeat(129); - - expect(() => - encode({ - available: false, - visible: true, - tabId, - url: null, - title: null, - loading: false, - }), - ).toThrow(); - }); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 00a58ecce4d5..b659054540c2 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -61,7 +61,7 @@ export const installPreviewEventForwarding = Effect.fn( ); }); -export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ +const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)), result: Schema.Void, @@ -71,7 +71,7 @@ export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ }), }); -export const createTab = DesktopIpc.makeIpcMethod({ +const createTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, payload: DesktopPreviewCreateTabInputSchema, result: Schema.Void, @@ -86,7 +86,7 @@ export const createTab = DesktopIpc.makeIpcMethod({ }), }); -export const closeTab = DesktopIpc.makeIpcMethod({ +const closeTab = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.Void, @@ -96,7 +96,7 @@ export const closeTab = DesktopIpc.makeIpcMethod({ }), }); -export const registerWebview = DesktopIpc.makeIpcMethod({ +const registerWebview = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, payload: DesktopPreviewRegisterWebviewInputSchema, result: Schema.Void, @@ -106,7 +106,7 @@ export const registerWebview = DesktopIpc.makeIpcMethod({ }), }); -export const navigate = DesktopIpc.makeIpcMethod({ +const navigate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL, payload: DesktopPreviewNavigateInputSchema, result: Schema.Void, @@ -134,42 +134,42 @@ const tabMethod = ( }), }); -export const goBack = tabMethod( +const goBack = tabMethod( IpcChannels.PREVIEW_GO_BACK_CHANNEL, "desktop.ipc.preview.goBack", (manager, tabId) => manager.goBack(tabId), ); -export const goForward = tabMethod( +const goForward = tabMethod( IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, "desktop.ipc.preview.goForward", (manager, tabId) => manager.goForward(tabId), ); -export const refresh = tabMethod( +const refresh = tabMethod( IpcChannels.PREVIEW_REFRESH_CHANNEL, "desktop.ipc.preview.refresh", (manager, tabId) => manager.refresh(tabId), ); -export const zoomIn = tabMethod( +const zoomIn = tabMethod( IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, "desktop.ipc.preview.zoomIn", (manager, tabId) => manager.zoomIn(tabId), ); -export const zoomOut = tabMethod( +const zoomOut = tabMethod( IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, "desktop.ipc.preview.zoomOut", (manager, tabId) => manager.zoomOut(tabId), ); -export const resetZoom = tabMethod( +const resetZoom = tabMethod( IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, "desktop.ipc.preview.resetZoom", (manager, tabId) => manager.resetZoom(tabId), ); -export const hardReload = tabMethod( +const hardReload = tabMethod( IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); -export const setZoomFactor = DesktopIpc.makeIpcMethod({ +const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, result: Schema.Void, @@ -178,7 +178,7 @@ export const setZoomFactor = DesktopIpc.makeIpcMethod({ yield* manager.setZoomFactor(tabId, zoomFactor); }), }); -export const setColorScheme = DesktopIpc.makeIpcMethod({ +const setColorScheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, payload: DesktopPreviewSetColorSchemeInputSchema, result: Schema.Void, @@ -187,7 +187,7 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({ yield* manager.setColorScheme(tabId, colorScheme); }), }); -export const setAudioMuted = DesktopIpc.makeIpcMethod({ +const setAudioMuted = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, payload: DesktopPreviewSetAudioMutedInputSchema, result: Schema.Void, @@ -196,12 +196,12 @@ export const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); -export const openDevTools = tabMethod( +const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", (manager, tabId) => manager.openDevTools(tabId), ); -export const cancelPickElement = tabMethod( +const cancelPickElement = tabMethod( IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), @@ -226,23 +226,23 @@ export const startRecording = DesktopIpc.makeIpcMethod({ ); }), }); -export const stopRecording = tabMethod( +const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", (manager, tabId) => manager.stopRecording(tabId), ); -export const openPictureInPicture = tabMethod( +const openPictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL, "desktop.ipc.preview.openPictureInPicture", (manager, tabId) => manager.openPictureInPicture(tabId), ); -export const closePictureInPicture = tabMethod( +const closePictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, "desktop.ipc.preview.closePictureInPicture", (manager, tabId) => manager.closePictureInPicture(tabId), ); -export const clearCookies = DesktopIpc.makeIpcMethod({ +const clearCookies = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, @@ -252,7 +252,7 @@ export const clearCookies = DesktopIpc.makeIpcMethod({ }), }); -export const clearCache = DesktopIpc.makeIpcMethod({ +const clearCache = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, payload: DesktopPreviewClearDataInputSchema, result: Schema.Void, @@ -308,7 +308,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); -export const getPreviewConfig = DesktopIpc.makeIpcMethod({ +const getPreviewConfig = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, payload: DesktopPreviewConfigInputSchema, result: DesktopPreviewWebviewConfigSchema, @@ -361,7 +361,7 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ }), }); -export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ +const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, result: Schema.Void, @@ -371,7 +371,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ }), }); -export const pickElement = DesktopIpc.makeIpcMethod({ +const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), @@ -381,7 +381,7 @@ export const pickElement = DesktopIpc.makeIpcMethod({ }), }); -export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ +const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_SEND_ENABLED_CHANNEL, payload: DesktopPreviewAnnotationSendEnabledInputSchema, result: Schema.Void, @@ -406,7 +406,7 @@ export const captureScreenshot = DesktopIpc.makeIpcMethod({ }), }); -export const revealArtifact = DesktopIpc.makeIpcMethod({ +const revealArtifact = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -416,7 +416,7 @@ export const revealArtifact = DesktopIpc.makeIpcMethod({ }), }); -export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ +const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, payload: DesktopPreviewArtifactInputSchema, result: Schema.Void, @@ -426,7 +426,7 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ }), }); -export const saveRecording = DesktopIpc.makeIpcMethod({ +const saveRecording = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, payload: DesktopPreviewRecordingSaveInputSchema, result: DesktopPreviewRecordingArtifactSchema, diff --git a/apps/desktop/src/ipc/methods/snapShot.test.ts b/apps/desktop/src/ipc/methods/snapShot.test.ts index 138bcbc36d30..7e1ae602f979 100644 --- a/apps/desktop/src/ipc/methods/snapShot.test.ts +++ b/apps/desktop/src/ipc/methods/snapShot.test.ts @@ -1,22 +1,15 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as DesktopSnapShot from "../../snapShot/DesktopSnapShot.ts"; import { - checkSnapShotShortcut, - requestSnapShotPermissions, setupSnapShot, previewSnapShotConfig, applySnapShotConfig, - setSnapShotAnimationDestination, - setSnapShotShortcutSuppressed, - snapShotScreenFrame, snapShotRelativeFrame, } from "./snapShot.ts"; @@ -74,193 +67,6 @@ describe("window capture IPC", () => { ); }); - it.effect("cancelling custom file selection reads and writes nothing", () => { - let read = false; - return Effect.gen(function* () { - const preview = yield* previewSnapShotConfig.handler( - { operation: "install", chooseFile: true }, - { sender: { id: 7 } }, - ); - assert.isNull(preview); - assert.isFalse(read); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - Layer.succeed(DesktopSnapShot.DesktopSnapShot, { - state: Effect.succeed({ - linuxBackend: "niri", - shortcutConfigPath: "/config/niri/config.kdl", - }), - previewConfig: () => - Effect.sync(() => { - read = true; - return configPreview; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - Layer.succeed(ElectronDialog.ElectronDialog, { - pickFiles: () => Effect.succeed([]), - } as unknown as ElectronDialog.ElectronDialog["Service"]), - ), - ), - ); - }); - - it.effect("uses only the file returned by the native custom config picker", () => { - let path: string | undefined; - return Effect.gen(function* () { - yield* previewSnapShotConfig.handler( - { operation: "install", chooseFile: true }, - { sender: { id: 7 } }, - ); - assert.equal(path, "/chosen/config.kdl"); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - Layer.succeed(DesktopSnapShot.DesktopSnapShot, { - state: Effect.succeed({ linuxBackend: "niri" }), - previewConfig: (_: unknown, selected: string) => - Effect.sync(() => { - path = selected; - return configPreview; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - Layer.succeed(ElectronDialog.ElectronDialog, { - pickFiles: () => Effect.succeed(["/chosen/config.kdl"]), - } as unknown as ElectronDialog.ElectronDialog["Service"]), - ), - ), - ); - }); - - it("converts renderer viewport coordinates from the content origin using the window zoom", () => { - assert.deepEqual( - snapShotScreenFrame( - { x: 12, y: 20, width: 208, height: 112 }, - { x: 100, y: 80, width: 1_000, height: 700 }, - 1.25, - ), - { x: 115, y: 105, width: 260, height: 140 }, - ); - }); - - it.effect("forwards a trusted renderer animation destination in screen coordinates", () => { - let received: unknown; - const webContents = { id: 7, getZoomFactor: () => 1.25 }; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ - getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), - getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), - webContents, - }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - setAnimationDestination: (id: string, destination: unknown) => - Effect.sync(() => { - received = { id, destination }; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* setSnapShotAnimationDestination.handler( - { - id: "12345678-1234-1234-1234-123456789abc", - viewportFrame: { x: 12, y: 20, width: 208, height: 112 }, - backgroundColor: "rgb(20, 20, 20)", - borderColor: "rgba(80, 80, 80, 0.8)", - borderWidth: 1, - cornerRadius: 8, - details: { - appName: "T3 Code", - windowTitle: "Capture animation", - appIconDataUrl: "data:image/png;base64,aWNvbg==", - }, - }, - { sender: webContents }, - ); - assert.deepEqual(received, { - id: "12345678-1234-1234-1234-123456789abc", - destination: { - relativeFrame: { x: 15 / 1000, y: 25 / 662, width: 260 / 1000, height: 140 / 662 }, - frame: { x: 115, y: 143, width: 260, height: 140 }, - backgroundColor: "rgb(20, 20, 20)", - borderColor: "rgba(80, 80, 80, 0.8)", - borderWidth: 1.25, - cornerRadius: 10, - scaleFactor: 1.25, - details: { - appName: "T3 Code", - windowTitle: "Capture animation", - appIconDataUrl: "data:image/png;base64,aWNvbg==", - }, - }, - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("forwards the accessibility permission preference from a trusted renderer", () => { - let includeAccessibility: boolean | undefined; - const webContents = { id: 7 }; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - requestPermissions: (include: boolean) => - Effect.sync(() => { - includeAccessibility = include; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* requestSnapShotPermissions.handler(false, { sender: webContents }); - assert.isFalse(includeAccessibility); - }).pipe(Effect.provide(layer)); - }); - - it.effect("rejects an untrusted renderer at the IPC boundary", () => - Effect.gen(function* () { - const exit = yield* Effect.exit( - requestSnapShotPermissions.handler(false, { sender: { id: 8 } }), - ); - assert(Exit.isFailure(exit)); - const failure = Cause.findErrorOption(exit.cause); - assert(Option.isSome(failure)); - const error = failure.value; - - assert.equal((error as { readonly _tag: string })._tag, "SnapShotIpcUnauthorizedSenderError"); - assert.equal((error as Error).message, "Snapshot request was rejected."); - }).pipe( - Effect.provideService( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Effect.provideService(DesktopSnapShot.DesktopSnapShot, null as never), - ), - ); - it.effect("allows capture setup only from the trusted main renderer", () => { const actions: string[] = []; return Effect.gen(function* () { @@ -287,56 +93,6 @@ describe("window capture IPC", () => { ), ); }); - - it.effect("checks shortcut availability for a trusted renderer", () => { - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - checkShortcut: () => Effect.succeed({ available: true, message: null }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - const result = yield* checkSnapShotShortcut.handler( - { kind: "both-shift-keys" }, - { sender: { id: 7 } }, - ); - assert.deepEqual(result, { available: true, message: null }); - }).pipe(Effect.provide(layer)); - }); - it.effect("suppresses the active shortcut for a trusted renderer", () => { - let suppressed = false; - const layer = Layer.mergeAll( - Layer.succeed( - ElectronWindow.ElectronWindow, - ElectronWindow.ElectronWindow.of({ - main: Effect.succeedSome({ webContents: { id: 7 } }), - } as ElectronWindow.ElectronWindow["Service"]), - ), - Layer.succeed( - DesktopSnapShot.DesktopSnapShot, - DesktopSnapShot.DesktopSnapShot.of({ - setShortcutSuppressed: (next: boolean) => - Effect.sync(() => { - suppressed = next; - }), - } as unknown as DesktopSnapShot.DesktopSnapShot["Service"]), - ), - ); - - return Effect.gen(function* () { - yield* setSnapShotShortcutSuppressed.handler(true, { sender: { id: 7 } }); - assert.isTrue(suppressed); - }).pipe(Effect.provide(layer)); - }); }); it("normalizes Wayland attachment coordinates without trusting Electron's screen origin", () => { diff --git a/apps/desktop/src/ipc/methods/snapShot.ts b/apps/desktop/src/ipc/methods/snapShot.ts index 4b5e189c2089..dff64081b70c 100644 --- a/apps/desktop/src/ipc/methods/snapShot.ts +++ b/apps/desktop/src/ipc/methods/snapShot.ts @@ -45,7 +45,7 @@ const ensureTrustedSnapShotSender = Effect.fn("desktop.ipc.snapShot.ensureTruste }, ); -export function snapShotScreenFrame( +function snapShotScreenFrame( viewportFrame: DesktopSnapShotAnimationDestination["viewportFrame"], contentBounds: Electron.Rectangle, zoomFactor: number, diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 0ed188ca61b5..1b40db955fe6 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -34,59 +34,6 @@ function layerHttpClient( } describe("SSH environment IPC", () => { - it.effect("fetches and decodes the remote environment descriptor", () => { - const requestUrls: string[] = []; - const layer = layerHttpClient((request) => - Effect.sync(() => { - requestUrls.push(request.url); - return jsonResponse(request, { - environmentId: "remote-env", - label: "Remote Devbox", - platform: { os: "linux", arch: "x64" }, - serverVersion: "1.2.3", - capabilities: { repositoryIdentity: true }, - }); - }), - ); - - return Effect.gen(function* () { - const descriptor = yield* fetchSshEnvironmentDescriptor.handler({ - httpBaseUrl: "http://127.0.0.1:41773/", - }); - - assert.deepEqual(descriptor, { - environmentId: "remote-env", - label: "Remote Devbox", - platform: { os: "linux", arch: "x64" }, - serverVersion: "1.2.3", - capabilities: { repositoryIdentity: true }, - }); - assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("wraps schema decode failures in a typed request error", () => { - const layer = layerHttpClient((request) => - Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - fetchSshEnvironmentDescriptor.handler({ - httpBaseUrl: "http://127.0.0.1:41773/", - }), - ); - assert(Exit.isFailure(exit)); - const failure = Cause.findErrorOption(exit.cause); - assert(Option.isSome(failure)); - const error = failure.value; - - assert.instanceOf(error, DesktopSshEnvironmentRequestError); - assert.equal(error.operation, "fetch-environment-descriptor"); - assert.equal(error.cause instanceof SshHttpBridgeError, false); - }).pipe(Effect.provide(layer)); - }); - it.effect("rejects non-loopback HTTP endpoints before issuing a request", () => { let requestCount = 0; const layer = layerHttpClient((request) => diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 049444fb7af8..1df67deef2a1 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -8,8 +8,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { vi } from "vite-plus/test"; -import type * as Electron from "electron"; - const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ focusedWebContents: vi.fn(), ownerWindow: vi.fn(), @@ -91,23 +89,6 @@ const bootstrapsLayer = (instances: ReadonlyArray { - it.effect("publishes the concrete running distro without replacing the stable instance id", () => - Effect.gen(function* () { - const result = yield* getLocalEnvironmentBootstraps.handler(); - - assert.deepEqual(result, [ - { - id: "wsl:default", - label: "WSL (Ubuntu)", - runningDistro: "Ubuntu", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - bootstrapToken: "bootstrap-token", - }, - ]); - }).pipe(Effect.provide(bootstrapsLayer([defaultWslInstance]))), - ); - it.effect("hands out the current window's token to a backend launched with the secret", () => Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 91c3aba206e4..e9b66f6a7463 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -100,68 +100,57 @@ export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ }), }); -const readLocalEnvironmentBootstrapSnapshot = Effect.fn( - "desktop.ipc.window.getLocalEnvironmentBootstraps", -)(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const instances = yield* pool.list; - const bootstraps: DesktopEnvironmentBootstrap[] = []; - for (const instance of instances) { - const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; - const config = yield* instance.currentConfig; - const snapshot = yield* instance.snapshot; - if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { - const fatalPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - config.value.preflightFailure.value.fatal; - const stoppedPreflight = - Option.isSome(config) && - Option.isSome(config.value.preflightFailure) && - (!snapshot.desiredRunning || !snapshot.restartScheduled); - if (isPrimary || fatalPreflight || stoppedPreflight) continue; +export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, + result: Schema.Array(DesktopEnvironmentBootstrapSchema), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const instances = yield* pool.list; + const bootstraps: DesktopEnvironmentBootstrap[] = []; + for (const instance of instances) { + const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; + const config = yield* instance.currentConfig; + const snapshot = yield* instance.snapshot; + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { + const fatalPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + config.value.preflightFailure.value.fatal; + const stoppedPreflight = + Option.isSome(config) && + Option.isSome(config.value.preflightFailure) && + (!snapshot.desiredRunning || !snapshot.restartScheduled); + if (isPrimary || fatalPreflight || stoppedPreflight) continue; + bootstraps.push({ + id: instance.id, + label: yield* instance.label, + runningDistro: null, + httpBaseUrl: null, + wsBaseUrl: null, + }); + continue; + } + const { bootstrap, httpBaseUrl } = config.value; + const runningDistro = config.value.runningDistro ?? null; bootstraps.push({ id: instance.id, - label: yield* instance.label, - runningDistro: null, - httpBaseUrl: null, - wsBaseUrl: null, + label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, + runningDistro, + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + // A backend launched with the desktop secret accepts whichever token the secret derives + // for the current window, so hand out that one rather than the token frozen into its + // launch config. + ...(bootstrap.desktopBootstrapSecret + ? { bootstrapToken: yield* configuration.currentBootstrapToken } + : bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), }); - continue; } - const { bootstrap, httpBaseUrl } = config.value; - const runningDistro = config.value.runningDistro ?? null; - bootstraps.push({ - id: instance.id, - label: runningDistro === null ? yield* instance.label : `WSL (${runningDistro})`, - runningDistro, - httpBaseUrl: httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. - ...(bootstrap.desktopBootstrapSecret - ? { bootstrapToken: yield* configuration.currentBootstrapToken } - : bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }); - } - return bootstraps; -}); - -export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: readLocalEnvironmentBootstrapSnapshot, -}); - -export const readLocalEnvironmentBootstraps = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, - payload: Schema.Void, - result: Schema.Array(DesktopEnvironmentBootstrapSchema), - handler: readLocalEnvironmentBootstrapSnapshot, + return bootstraps; + }), }); function extractWslDistroFromEnvironmentId(envId: string): string | null { diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 34c3c9080eac..af42c7264748 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -17,7 +17,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -84,110 +84,6 @@ const layerUnusedLifecycleRuntime = Layer.mergeAll( ); describe("WSL IPC", () => { - it.effect("does not probe WSL when local execution is disabled", () => - Effect.gen(function* () { - const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const state = yield* getWslState.handler(undefined).pipe( - Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { - ...wsl, - isAvailable: Effect.die("must not probe WSL"), - listDistros: Effect.die("must not enumerate distros"), - }), - Effect.flatMap(decodeWslState), - ); - assert.deepEqual(state, { - enabled: true, - distro: "Ubuntu", - available: false, - wslOnly: true, - distros: [], - preflightError: null, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: true, - }), - DesktopWslEnvironment.layerTest(), - layerWslBackend(), - ), - ), - ), - ); - - it.effect("does not enumerate distros until the WSL backend is enabled", () => { - const distros = [ - { name: "Ubuntu", isDefault: true, version: 2 as const }, - { name: "Debian", isDefault: false, version: 2 as const }, - ]; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslDistro: "Debian", - }), - DesktopWslEnvironment.layerTest({ isAvailable: true, distros }), - layerWslBackend(), - layerLifecycle([]), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const state = yield* getWslState.handler(undefined).pipe( - Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { - ...wsl, - listDistros: Effect.die("must not enumerate distros"), - }), - Effect.flatMap(decodeWslState), - ); - assert.equal(state.enabled, false); - assert.equal(state.available, true); - assert.equal(state.distro, "Debian"); - assert.deepEqual(state.distros, []); - - const enabledState = yield* invokeSetWslBackendEnabled(true); - assert.equal(enabledState.enabled, true); - assert.equal(enabledState.distro, "Debian"); - assert.deepEqual(enabledState.distros, distros); - }).pipe(Effect.provide(layer)); - }); - - it.effect("stages dual-backend preferences before enabling without relaunching", () => { - const relaunchReasons: Array = []; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend(), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - yield* invokeSetWslOnly(false); - yield* invokeSetWslDistro("Debian"); - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: "Debian", - available: true, - wslOnly: false, - distros: [], - preflightError: null, - }); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("stages WSL-only preferences and relaunches only after enabling", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( @@ -224,70 +120,6 @@ describe("WSL IPC", () => { }).pipe(Effect.provide(layer)); }); - it.effect("relaunches when enabling the WSL backend while wsl-only is already persisted", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: true, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.deepEqual(state, { - enabled: true, - distro: null, - available: true, - wslOnly: true, - distros: [], - preflightError: null, - }); - assert.equal(reconcileCount, 0); - assert.deepEqual(relaunchReasons, ["wslBackendEnabled=true"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reconciles in dual-backend mode without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: false, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(true); - - assert.equal(state.enabled, true); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); - it.effect("clears wsl-only before relaunching when disabling a WSL-only backend", () => { const relaunchReasons: Array = []; let reconcileCount = 0; @@ -326,33 +158,4 @@ describe("WSL IPC", () => { assert.deepEqual(relaunchReasons, ["wslBackendEnabled=false"]); }).pipe(Effect.provide(layer)); }); - - it.effect("clears dual-backend WSL without relaunching", () => { - const relaunchReasons: Array = []; - let reconcileCount = 0; - const layer = Layer.mergeAll( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslOnly: false, - }), - DesktopWslEnvironment.layerTest({ isAvailable: true }), - layerWslBackend({ - onReconcile: Effect.sync(() => { - reconcileCount += 1; - }), - }), - layerLifecycle(relaunchReasons), - layerUnusedLifecycleRuntime, - ); - - return Effect.gen(function* () { - const state = yield* invokeSetWslBackendEnabled(false); - - assert.equal(state.enabled, false); - assert.equal(state.wslOnly, false); - assert.equal(reconcileCount, 1); - assert.deepEqual(relaunchReasons, []); - }).pipe(Effect.provide(layer)); - }); }); diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 6a36e87bbfcb..3c10f04512b4 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,9 +22,9 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Windows ships a wsl.exe stub that can prompt to install WSL when invoked. - // Keep disabled state reads from launching it to enumerate distros. - const distros = available && settings.wslBackendEnabled ? yield* wslEnvironment.listDistros : []; + // Only enumerate distros when WSL is actually available — listDistros on a + // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { enabled: settings.wslBackendEnabled, diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index 157175e53332..aaf0c11bcce9 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -1,37 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { - normalizeLinuxPasswordStorePreference, - resolveLinuxPasswordStoreSwitch, -} from "./linuxSecretStorage.ts"; +import { resolveLinuxPasswordStoreSwitch } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); describe("linuxSecretStorage", () => { - it("preserves explicit supported password-store preferences", () => { - expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); - expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); - expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); - expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); - }); - - it("falls back to auto for missing or unsupported preferences", () => { - expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); - expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); - }); - - it("uses explicit preferences instead of the auto heuristic", () => { - for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { - expect( - resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), - ).toBe(preference); - expect( - resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), - ).toBe(preference); - } - }); - it("leaves canonical KDE sessions to Electron's own wallet selection", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); @@ -39,13 +13,6 @@ describe("linuxSecretStorage", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); }); - it("does not force a password-store for desktops Electron already recognizes", () => { - expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); - for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { - expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); - } - }); - it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5e1085ab5b10..a4a9d3e39b44 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,6 +6,7 @@ for (const stream of [process.stdout, process.stderr]) { } import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. import * as NodeFS from "node:fs"; @@ -21,7 +22,6 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; -import * as DesktopRuntime from "./app/DesktopRuntime.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -38,7 +38,6 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; -import * as DesktopSafeStorageIdentity from "./app/DesktopSafeStorageIdentity.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; @@ -148,10 +147,7 @@ const layerDesktopFoundation = Layer.mergeAll( DesktopLegacyLocalStorage.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopConnectionCatalogStore.layer.pipe( - Layer.provideMerge(DesktopSavedEnvironments.layer), - Layer.provide(DesktopSafeStorageIdentity.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), - ), + DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, DesktopRendererHistory.layer, @@ -252,4 +248,4 @@ const layerDesktopRuntime = layerDesktopClerk.pipe( Layer.provideMerge(DesktopPreReadyPlatform.layer), ); -DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), DesktopRuntime.runMain); +DesktopApp.program.pipe(Effect.provide(layerDesktopRuntime), NodeRuntime.runMain); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.test.ts b/apps/desktop/src/permissions/MacPermissionHelper.test.ts index e1160d9bbe38..0704f52254ef 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.test.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.test.ts @@ -1,8 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import * as Electron from "electron"; -import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { MacPermissionHelper } from "./MacPermissionHelper.ts"; import type { SettingsWindow } from "./MacSettingsWindow.ts"; -import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; const mocks = vi.hoisted(() => ({ granted: false, @@ -110,193 +108,6 @@ afterEach(() => { const iconPaths = ["/bundle/prod-resources/icon.png"]; const open = () => helper.show("accessibility", "/bundle/mac-permission-preload.cjs", null, iconPaths); -function send(action: string, trusted = true) { - const window = windows.at(-1)!; - Electron.ipcMain.emit( - MAC_PERMISSION_HELPER_CHANNEL, - { - sender: trusted ? window.webContents : {}, - senderFrame: window.webContents.mainFrame, - }, - action, - ); -} - -describe("macAppBundlePath", () => { - it("resolves bundles with spaces and refuses non-bundle executables", () => { - expect(macAppBundlePath("/Applications/T3 Code.app/Contents/MacOS/T3 Code")).toBe( - "/Applications/T3 Code.app", - ); - expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined(); - expect(macAppBundlePath("/Applications/T3 Code.app/other/MacOS/T3 Code")).toBeUndefined(); - }); -}); -it("drags the running app bundle only for the helper's own renderer", async () => { - await open(); - send("drag", false); - expect(mocks.startDrag).not.toHaveBeenCalled(); - send("drag"); - expect(mocks.createFromPath).toHaveBeenCalledWith("/bundle/prod-resources/icon.png"); - expect(mocks.startDrag).toHaveBeenCalledWith({ - file: "/Applications/T3 Code (Nightly).app", - icon: mocks.createFromPath.mock.results[0]!.value.resize(), - }); - send("finder"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); -}); -it("rechecks permissions and releases resources when granted", async () => { - await open(); - mocks.granted = true; - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); -it("keeps only one helper and cleans up on dismissal", async () => { - await open(); - await helper.show("screen-recording", "/preload.cjs", null, iconPaths); - expect(windows[0]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(1); - send("close"); - expect(windows[1]!.destroyed).toBe(true); - expect(vi.getTimerCount()).toBe(0); -}); -it("does not open for a permission already granted", async () => { - mocks.granted = true; - await open(); - expect(windows).toHaveLength(0); -}); -it("does not show a helper with a missing packaged icon", async () => { - mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); - await expect(open()).rejects.toThrow("packaged T3 Code icon is missing"); - expect(windows).toHaveLength(0); -}); -it("cleans up when the helper page fails to load", async () => { - mocks.loadURL.mockRejectedValueOnce(new Error("load failed")); - await expect(open()).rejects.toThrow("load failed"); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); - -it("offers the Finder fallback when native dragging fails", async () => { - await open(); - mocks.startDrag.mockImplementationOnce(() => { - throw new Error("drag failed"); - }); - send("drag"); - expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (Nightly).app"); - expect(windows[0]!.destroyed).toBe(false); -}); - -it("returns focus to onboarding when the permission is granted", async () => { - const owner = new Electron.BrowserWindow({}); - await helper.show("screen-recording", "/preload.cjs", owner, iconPaths); - mocks.granted = true; - await vi.advanceTimersByTimeAsync(1000); - expect(owner.show).toHaveBeenCalledOnce(); - expect(owner.focus).toHaveBeenCalledOnce(); - expect(windows[1]!.destroyed).toBe(true); - expect(owner.listenerCount("closed")).toBe(0); -}); -it("closes the helper and stops checking when onboarding's window closes", async () => { - const owner = new Electron.BrowserWindow({}); - await helper.show("accessibility", "/preload.cjs", owner, iconPaths); - owner.destroy(); - expect(windows[1]!.destroyed).toBe(true); - expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0); - expect(vi.getTimerCount()).toBe(0); -}); - -it("uses the packaged PNG when an earlier resource candidate is absent", async () => { - mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true }); - await helper.show("accessibility", "/preload.cjs", null, ["/missing/icon.png", ...iconPaths]); - send("drag"); - expect(mocks.startDrag).toHaveBeenCalled(); - expect(mocks.createFromPath).toHaveBeenLastCalledWith(iconPaths[0]); -}); - -it("docks inside Settings and hides when it is covered or closed", async () => { - await open(); - const window = windows[0]!; - const settings = { x: 367, y: 100, width: 723, height: 719, frontmost: true }; - mocks.settingsChanged!(settings); - expect(window.setBounds).toHaveBeenLastCalledWith( - { x: 599, y: 663, width: 475, height: 140 }, - false, - ); - expect(window.showInactive).toHaveBeenCalledOnce(); - mocks.settingsChanged!({ ...settings, x: -800, y: 200 }); - expect(window.setBounds).toHaveBeenLastCalledWith( - { x: -568, y: 763, width: 475, height: 140 }, - false, - ); - mocks.settingsChanged!({ ...settings, frontmost: false }); - expect(window.hide).toHaveBeenCalledOnce(); - mocks.settingsChanged!(null); - expect(window.destroyed).toBe(true); - helper.close(); - expect(mocks.stopTracking).toHaveBeenCalledOnce(); -}); - -it("returns to onboarding when the Settings window disappears", async () => { - const owner = new Electron.BrowserWindow({}); - owner.hide(); - await helper.show("accessibility", "/preload.cjs", owner, iconPaths); - mocks.settingsChanged!({ x: 100, y: 100, width: 723, height: 719, frontmost: true }); - mocks.settingsChanged!(null); - expect(owner.show).toHaveBeenCalledOnce(); - expect(owner.focus).toHaveBeenCalledOnce(); -}); -it("hides on tracking failure and resumes on a valid update", async () => { - await open(); - const state = { x: 100, y: 100, width: 723, height: 719, frontmost: true }; - mocks.settingsChanged!(state); - mocks.trackingFailed!(); - expect(windows[0]!.destroyed).toBe(false); - expect(windows[0]!.hide).toHaveBeenCalledOnce(); - mocks.settingsChanged!(state); - expect(windows[0]!.showInactive).toHaveBeenCalledTimes(2); -}); - -it("waits for an asynchronous Full Disk Access check and returns to the owner", async () => { - const owner = new Electron.BrowserWindow(); - const probe = vi.fn<() => Promise>().mockResolvedValue(false); - await helper.show( - "full-disk-access", - "/bundle/mac-permission-preload.cjs", - owner, - iconPaths, - probe, - ); - const pending = Promise.withResolvers(); - probe.mockReturnValue(pending.promise); - await vi.advanceTimersByTimeAsync(3000); - expect(probe).toHaveBeenCalledTimes(2); - expect(windows[1]!.destroyed).toBe(false); - pending.resolve(true); - await pending.promise; - expect(windows[1]!.destroyed).toBe(true); - expect(owner.focus).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("keeps the helper open after a failed access check and retries", async () => { - const probe = vi.fn<() => Promise>().mockResolvedValue(false); - await helper.show( - "full-disk-access", - "/bundle/mac-permission-preload.cjs", - null, - iconPaths, - probe, - ); - probe.mockRejectedValueOnce(new Error("temporarily unavailable")); - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(false); - probe.mockResolvedValue(true); - await vi.advanceTimersByTimeAsync(1000); - expect(windows[0]!.destroyed).toBe(true); -}); - it("does not reopen a superseded helper when its initial probe completes", async () => { const pending = Promise.withResolvers(); const first = helper.show( diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index dfc378cfd1c4..4a8e38961f20 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -18,7 +18,7 @@ const permissionGranted = (permission: MacPermission) => { return false; }; -export function macAppBundlePath(executable: string): string | undefined { +function macAppBundlePath(executable: string): string | undefined { return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; } diff --git a/apps/desktop/src/permissions/MacSettingsWindow.test.ts b/apps/desktop/src/permissions/MacSettingsWindow.test.ts index 3071b00c3264..d07b4f5f9a76 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.test.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.test.ts @@ -1,6 +1,6 @@ import * as NodeEvents from "node:events"; import { beforeEach, expect, it, vi } from "vite-plus/test"; -import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts"; +import { watchMacSettingsWindow } from "./MacSettingsWindow.ts"; const mocks = vi.hoisted(() => ({ spawn: vi.fn() })); vi.mock("node:child_process", () => ({ spawn: mocks.spawn })); @@ -15,17 +15,6 @@ beforeEach(() => { }); mocks.spawn.mockReturnValue(child); }); -it("places the helper inside small and large Settings windows across displays", () => { - for (const x of [-900, 20]) { - for (const width of [668, 1000]) { - const state = { x, y: 30, width, height: 700, frontmost: true }; - const helper = settingsHelperBounds(state); - expect(helper.x).toBeGreaterThanOrEqual(x + 216); - expect(helper.x + helper.width).toBeLessThanOrEqual(x + width - 16); - expect(helper.y + helper.height).toBe(714); - } - } -}); it("decodes partial updates and stops the one watcher process on disposal", () => { const changed = vi.fn(); const unavailable = vi.fn(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1a0f97bbed20..b502d4be5b27 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -12,7 +12,6 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; import { mergeLegacyLocalStorage } from "./legacyLocalStorageMerge.ts"; -import { createPreloadSnapshot } from "./preloadSnapshot.ts"; const SNAP_SHOT_EVENT_TYPES = new Set([ "requested", @@ -75,18 +74,6 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { return result as Awaited>; } -const readBootstraps = ( - result: unknown, -): ReturnType => - Array.isArray(result) ? result : []; -const localEnvironmentBootstraps = createPreloadSnapshot( - () => readBootstraps(ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), - async () => - readBootstraps(await ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL)), -); -// Changes take effect on relaunch, so renderer reads can reuse the startup value. -let localEnvironmentEnabled: boolean | undefined; - contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); @@ -116,13 +103,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; }, - getLocalEnvironmentBootstraps: localEnvironmentBootstraps.read, - readLocalEnvironmentBootstraps: localEnvironmentBootstraps.refresh, + getLocalEnvironmentBootstraps: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); + if (!Array.isArray(result)) { + return []; + } + return result as ReturnType; + }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), getLocalEnvironmentEnabled: () => - (localEnvironmentEnabled ??= - ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false), + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, setLocalEnvironmentEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), diff --git a/apps/desktop/src/preloadSnapshot.test.ts b/apps/desktop/src/preloadSnapshot.test.ts deleted file mode 100644 index 05783e830032..000000000000 --- a/apps/desktop/src/preloadSnapshot.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, it, vi } from "vite-plus/test"; - -import { createPreloadSnapshot } from "./preloadSnapshot.ts"; - -it("serves the startup snapshot while a shared asynchronous refresh is pending", async () => { - const initial = ["primary"]; - const latest = Promise.withResolvers(); - const readInitial = vi.fn(() => initial); - const readLatest = vi.fn(() => latest.promise); - const snapshot = createPreloadSnapshot(readInitial, readLatest); - - expect(snapshot.read()).toBe(initial); - const first = snapshot.refresh(); - const second = snapshot.refresh(); - expect(snapshot.read()).toBe(initial); - expect(snapshot.read()).toBe(initial); - expect(readInitial).toHaveBeenCalledTimes(1); - expect(readLatest).toHaveBeenCalledTimes(1); - - const connected = ["primary", "wsl"]; - latest.resolve(connected); - expect(await first).toBe(connected); - expect(await second).toBe(connected); - expect(snapshot.read()).toBe(connected); - expect(readInitial).toHaveBeenCalledTimes(1); -}); - -it("retains an async snapshot through failure and replaces it on successful recovery", async () => { - const connected = ["primary", "wsl"]; - const readInitial = vi.fn(() => ["stale"]); - const readLatest = vi - .fn<() => Promise>() - .mockResolvedValueOnce(connected) - .mockRejectedValueOnce(new Error("IPC unavailable")) - .mockResolvedValueOnce([]); - const snapshot = createPreloadSnapshot(readInitial, readLatest); - - await snapshot.refresh(); - expect(snapshot.read()).toBe(connected); - await expect(snapshot.refresh()).rejects.toThrow("IPC unavailable"); - expect(snapshot.read()).toBe(connected); - await expect(snapshot.refresh()).resolves.toEqual([]); - expect(snapshot.read()).toEqual([]); - expect(readInitial).not.toHaveBeenCalled(); -}); diff --git a/apps/desktop/src/preloadSnapshot.ts b/apps/desktop/src/preloadSnapshot.ts deleted file mode 100644 index 4fe34b2719f6..000000000000 --- a/apps/desktop/src/preloadSnapshot.ts +++ /dev/null @@ -1,23 +0,0 @@ -export function createPreloadSnapshot(readInitial: () => T, readLatest: () => Promise) { - let snapshot: { readonly value: T } | undefined; - let pending: Promise | undefined; - - return { - read: () => { - snapshot ??= { value: readInitial() }; - return snapshot.value; - }, - refresh: () => { - if (pending !== undefined) return pending; - pending = readLatest() - .then((value) => { - snapshot = { value }; - return value; - }) - .finally(() => { - pending = undefined; - }); - return pending; - }, - }; -} diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 86eeccc92ab6..1104c422f3ee 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -16,10 +16,4 @@ describe("createAnnotationIdSource", () => { expect(secondAnnotation).not.toBe(firstAnnotation); }); - - it("never repeats an id within one page load", () => { - const nextId = createAnnotationIdSource(); - const ids = Array.from({ length: 1_000 }, () => nextId("annotation")); - expect(new Set(ids).size).toBe(ids.length); - }); }); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts index 4124e548bec1..11fb9100e398 100644 --- a/apps/desktop/src/preview/AnnotationKeyboard.test.ts +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -14,24 +14,9 @@ const keyboardEvent = ( }); describe("resolveAnnotationSubmission", () => { - it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { - expect(resolveAnnotationSubmission(keyboardEvent(), true)).toBe("attach"); - expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), true)).toBe("send"); - expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }), true)).toBe("send"); - }); - it("leaves Shift+Enter and composition events available for editing", () => { expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }), true)).toBeNull(); expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }), true)).toBeNull(); expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }), true)).toBeNull(); }); - - it("keeps attach available while the send shortcut follows permission changes", () => { - const send = keyboardEvent({ ctrlKey: true }); - expect(resolveAnnotationSubmission(send, false)).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent(), false)).toBe("attach"); - expect(resolveAnnotationSubmission(send, true)).toBe("send"); - expect(resolveAnnotationSubmission(send, false)).toBeNull(); - expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }), false)).toBeNull(); - }); }); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 22e384834789..4815da11cba1 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -3,9 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; @@ -120,31 +118,6 @@ describe("BrowserImport.importCookies", () => { }); describe("BrowserImport.writeCookies", () => { - it.effect("counts a rejected cookie and its domain as skipped", () => - Effect.gen(function* () { - let flushes = 0; - const result = yield* BrowserImport.writeCookies( - { - cookies: { - set: () => Promise.reject(new Error("fixture rejection")), - flushStore: () => { - flushes += 1; - return Promise.resolve(); - }, - }, - }, - { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, - ); - - assert.deepEqual(result, { - imported: 0, - skipped: 1, - skippedDomains: ["rejected.example"], - }); - assert.equal(flushes, 0); - }), - ); - it.effect("flushes the store after writing, and reports success if the flush fails", () => Effect.gen(function* () { const events: Array = []; @@ -168,28 +141,4 @@ describe("BrowserImport.writeCookies", () => { assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); }), ); - - it.effect("propagates interruption while writing a cookie", () => - Effect.gen(function* () { - const write = BrowserImport.writeCookies( - { - cookies: { - set: () => new Promise(() => {}), - flushStore: () => Promise.resolve(), - }, - }, - { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, - ); - - const interrupted = yield* Ref.make(false); - const fiber = yield* write.pipe( - Effect.onInterrupt(() => Ref.set(interrupted, true)), - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* Fiber.interrupt(fiber); - - assert.isTrue(yield* Ref.get(interrupted)); - }), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 7cf73ef633b0..91be0a43d1be 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -47,17 +47,6 @@ describe("cookieScope", () => { }); }); - it("preserves a domain cookie's leading dot", () => { - expect(cookieScope(".example.test", "/app", true)).toEqual({ - url: "https://example.test/app", - domain: ".example.test", - }); - }); - - it("matches the scheme to the secure flag", () => { - expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); - }); - it("brackets bare IPv6 hosts without duplicating existing brackets", () => { expect(cookieScope("::1", "/", false)).toEqual({ url: "http://[::1]/", @@ -151,57 +140,56 @@ describe("readChromiumCookieDatabase", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect.each([14, 24])( - "imports mixed TEXT and BLOB cookie values, including empty values, in schema %i", - (schemaVersion) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - const key = Buffer.from("0123456789abcdef"); - const host = "secure.example"; - const value = "stored encrypted"; - const payload = - schemaVersion >= 24 - ? Buffer.concat([ - NodeCrypto.createHash("sha256").update(host).digest(), - Buffer.from(value), - ]) - : Buffer.from(value); - const encrypted = encryptV10(payload, key); + it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-chromium-cookies-", + }); + const filename = `${directory}/Cookies`; + const key = Buffer.from("0123456789abcdef"); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', ${schemaVersion})`; - yield* sql`create table cookies ( - host_key text not null, name text not null, value text not null, - encrypted_value blob not null, path text not null, expires_utc integer not null, - is_secure integer not null, is_httponly integer not null, samesite integer not null - )`; - if (schemaVersion >= 15) { - yield* sql`alter table cookies add column top_frame_site_key text not null default ''`; - } - yield* sql`insert into cookies - (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('plain.example', 'plain', 'stored plaintext', '', '/', 0, 0, 0, -1), - (${host}, 'text-encrypted', '', cast(${encrypted} as text), '/', 0, 1, 1, 2), - (${host}, 'blob-encrypted', '', ${encrypted}, '/', 0, 1, 1, 2), - ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`create table meta (key text primary key, value text not null)`; + yield* sql`insert into meta values ('version', 23)`; + yield* sql` + create table cookies ( + host_key text not null, + name text not null, + value text not null, + encrypted_value blob not null, + path text not null, + expires_utc integer not null, + is_secure integer not null, + is_httponly integer not null, + samesite integer not null, + top_frame_site_key text not null default '' + ) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) + `; + yield* sql` + insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values + ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) + `; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); + const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); - expect(result.undecryptable).toBe(0); - expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ - { name: "plain", value: "stored plaintext" }, - { name: "text-encrypted", value }, - { name: "blob-encrypted", value }, - { name: "empty", value: "" }, - ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + expect(result.undecryptable).toBe(0); + expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "plain", value: "stored plaintext" }, + { name: "encrypted", value: "stored encrypted" }, + { name: "empty", value: "" }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("enforces domain binding only for schema 24 and newer", () => @@ -339,62 +327,6 @@ describe("readChromiumCookieDatabase", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - const key = Buffer.from("0123456789abcdef"); - const value = "x".repeat(32) + " legacy value"; - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', 23)`; - yield* sql` - create table cookies ( - host_key text not null, name text not null, value text not null, - encrypted_value blob not null, path text not null, expires_utc integer not null, - is_secure integer not null, is_httponly integer not null, samesite integer not null, - top_frame_site_key text not null default '' - ) - `; - yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values - ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - - const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); - expect(result.cookies[0]?.value).toBe(value); - expect(result.undecryptable).toBe(0); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("rejects a malformed text schema version", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-chromium-cookies-", - }); - const filename = `${directory}/Cookies`; - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`create table meta (key text primary key, value text not null)`; - yield* sql`insert into meta values ('version', 'not-a-version')`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - - const error = yield* readChromiumCookieDatabase( - filename, - { cbcV10: Buffer.from("0123456789abcdef") }, - "darwin", - ).pipe(Effect.flip); - - expect(error._tag).toBe("SchemaError"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index a3720f962b65..d5f7592b2606 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -200,14 +200,12 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu Effect.flatMap(decodeSchemaVersion), Effect.map(([row]) => row.value), ); - // SQLite's BLOB affinity still permits TEXT. Cast before Node decodes - // those bytes as UTF-8, which can corrupt an encrypted payload. const raw = schemaVersion >= 15 - ? yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, + ? yield* sql`select host_key, name, value, encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, top_frame_site_key from cookies` - : yield* sql`select host_key, name, value, cast(encrypted_value as blob) as encrypted_value, path, + : yield* sql`select host_key, name, value, encrypted_value, path, expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, samesite, '' as top_frame_site_key from cookies`; return { rows: yield* decodeCookieRows(raw), schemaVersion }; diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts deleted file mode 100644 index 890d9e271586..000000000000 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { ChildProcessSpawner } from "effect/process"; -import { vi } from "vite-plus/test"; - -vi.mock("@napi-rs/keyring", () => { - throw new Error("Cannot find native binding"); -}); - -it.effect("reports an unavailable keychain when the macOS binding cannot load", () => - Effect.gen(function* () { - const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( - () => import("./ChromiumKeys.ts"), - ); - const error = yield* resolveChromiumKeys({ - platform: "darwin", - keychainService: "Chrome Safe Storage", - keychainAccount: "Chrome", - linuxSecretApplication: undefined, - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(ChromiumKeyError); - expect(error.reason).toBe("keychainUnavailable"); - expect(error.cause).toBeInstanceOf(Error); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("must not spawn")), - ), - ), -); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index cdeb67e0e8cc..6fc5b7ae9faa 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -96,14 +96,6 @@ describe("macOS Chromium secrets", () => { }).pipe(Effect.provide(layerNoProcesses)), ); - it.effect("reports a missing keychain entry", () => - Effect.gen(function* () { - getPassword.mockReturnValue(null); - const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); - expect(error.reason).toBe("keychainItemMissing"); - }).pipe(Effect.provide(layerNoProcesses)), - ); - it.effect("preserves a denied keychain approval", () => Effect.gen(function* () { const denied = new Error("User denied access"); @@ -154,40 +146,6 @@ describe("Linux Chromium secrets", () => { ), ); - it.effect("looks up the browser's libsecret application attribute", () => { - let captured: CapturedCommand | undefined; - return Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: "ignored macOS service", - keychainAccount: "ignored macOS account", - linuxSecretApplication: "msedge", - }); - - expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret"); - expect(captured?.args).toEqual(["msedge"]); - expect(captured?.options.stdin).toBe("ignore"); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toHaveLength(16); - }).pipe( - Effect.provide( - layerHelper({ stdout: "linux-secret", capture: (value) => (captured = value) }), - ), - ); - }); - - it.effect("reports an unavailable Secret Service backend as a read failure", () => - Effect.gen(function* () { - const error = yield* readLinuxSecret("chrome").pipe(Effect.flip); - expect(error).toBeInstanceOf(ChromiumKeyError); - expect(error.reason).toBe("keychainUnavailable"); - }).pipe( - Effect.provide( - layerHelper({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }), - ), - ), - ); - it.effect("preserves trailing whitespace in the stored secret", () => Effect.gen(function* () { const secret = yield* readLinuxSecret("chrome"); @@ -240,51 +198,6 @@ describe("Linux Chromium secrets", () => { ); }, ); - - it.effect("does not discard a denied unlock prompt while resolving keys", () => - Effect.gen(function* () { - const error = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "brave", - }).pipe(Effect.flip); - expect(error.reason).toBe("needsKeychainApproval"); - }).pipe(Effect.provide(layerHelper({ stderr: "Keyring is locked", exitCode: 3 }))), - ); - - it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () => - Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "chrome", - }); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toBeUndefined(); - }).pipe( - Effect.provide( - layerHelper({ - stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", - exitCode: 1, - }), - ), - ), - ); - - it.effect("keeps the v10 fallback when no matching v11 secret exists", () => - Effect.gen(function* () { - const keys = yield* resolveChromiumKeys({ - platform: "linux", - keychainService: undefined, - keychainAccount: undefined, - linuxSecretApplication: "vivaldi", - }); - expect(keys.cbcV10).toHaveLength(16); - expect(keys.cbcV11).toBeUndefined(); - }).pipe(Effect.provide(layerHelper({ exitCode: 2 }))), - ); }); describe("Windows Chromium secrets", () => { diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index 18bcd7581b4f..402397d3fde6 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -62,23 +62,4 @@ describe("snapshotCookieDatabase", () => { }), ), ); - - it.effect("removes a successful snapshot when its scope closes", () => - runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-cookie-cleanup-source-", - }); - const source = path.join(sourceDirectory, "Cookies"); - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); - const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); - expect(yield* fileSystem.exists(snapshot)).toBe(false); - }), - ), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 9f28387d4f14..d764b5bd6d00 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -370,14 +370,6 @@ describe("parseFirefoxProfiles", () => { }), ); - it.effect("falls back to the path when a profile has no name", () => - Effect.gen(function* () { - expect( - yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), - ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); - }), - ); - it.effect.each([ { platform: "Linux", root: "/home/user/.mozilla/firefox" }, { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" }, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index fa369c8edc62..5420b9fc8bd5 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -11,7 +11,6 @@ import { parseBinaryCookies, readSafariCookies, safariAccessDenied, - safariAccessGranted, SafariCookieReadError, } from "./SafariCookies.ts"; @@ -138,17 +137,6 @@ describe("parseBinaryCookies", () => { }); }); - it("brackets IPv6 hosts in the cookie URL", () => { - const file = encodeBinaryCookies([ - { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, - ]); - - expect(parseBinaryCookies(file)[0]).toMatchObject({ - url: "http://[::1]/", - domain: undefined, - }); - }); - it("reads cookies spread across multiple pages", () => { const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, @@ -191,34 +179,6 @@ describe("parseBinaryCookies", () => { ); }); - it("rejects a record whose declared size runs past its page", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(0xffff, recordStart); - - expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); - }); - - it("rejects records truncated inside the 56-byte header", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - - for (let size = 48; size < 56; size += 1) { - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(size, recordStart); - expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( - SafariCookieReadError, - ); - } - }); - it("rejects record offsets that point into the page header or an earlier record", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, @@ -238,22 +198,6 @@ describe("parseBinaryCookies", () => { expect(parseBinaryCookies(valid)).toHaveLength(2); }); - it("rejects string offsets that point into the record header", () => { - const valid = encodeBinaryCookies([ - { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, - ]); - const pageStart = 8 + 4; - const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); - - for (const offsetField of [16, 20, 24, 28]) { - const corrupt = Buffer.from(valid); - corrupt.writeUInt32LE(55, recordStart + offsetField); - expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( - SafariCookieReadError, - ); - } - }); - it("accepts the checksum and property-list trailer Safari writes", () => { const file = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, @@ -266,48 +210,9 @@ describe("parseBinaryCookies", () => { expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); }); - - it("rejects a jar whose page table stops short of its contents", () => { - const first = encodeBinaryCookies([ - { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, - ]); - const extraPage = encodeBinaryCookies([ - { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, - ]).subarray(12); - - expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( - SafariCookieReadError, - ); - const badLength = Buffer.alloc(4); - badLength.writeUInt32BE(99, 0); - expect(() => - parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), - ).toThrow(SafariCookieReadError); - }); - - it("rejects a file that is not binarycookies", () => { - expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( - SafariCookieReadError, - ); - }); }); describe("readSafariCookies", () => { - it.effect("adds the cookie path and parser cause to malformed jar failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - const jar = `${directory}/Cookies.binarycookies`; - yield* fileSystem.writeFileString(jar, "not a cookie jar"); - - const error = yield* readSafariCookies(jar).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - assert.equal(error.cookieDatabasePath, jar); - assert.instanceOf(error.cause, SafariCookieReadError); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { // What Full Disk Access actually looks like: the file is there, the read @@ -329,54 +234,9 @@ describe("readSafariCookies", () => { assert.equal(error.reason, "needsFullDiskAccess"); }), ); - - it.effect("reports an ordinary permission failure as a plain read failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - const jar = `${directory}/Cookies.binarycookies`; - yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); - yield* fileSystem.chmod(jar, 0o000); - - const error = yield* readSafariCookies(jar).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("reports a missing jar as a plain read failure", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); - - const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); - - assert.equal(error.reason, "readFailed"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); }); describe("safariAccessDenied", () => { - const eperm = PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "open", - pathOrDescriptor: "/protected/Cookies.binarycookies", - cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), - }); - const denied = (error: PlatformError.PlatformError) => - FileSystem.layerNoop({ open: () => Effect.fail(error) }); - - it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => - Effect.gen(function* () { - assert.isTrue( - yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( - Effect.provide(denied(eperm)), - ), - ); - }), - ); - it.effect("does not read a readable jar, or any other failure, as denied", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -393,48 +253,7 @@ describe("isPermissionDenied", () => { const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; - it("treats a TCC EPERM denial as permission denied", () => { - expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); - }); - it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); }); - - it("does not treat an unrelated failure as permission denied", () => { - expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); - }); -}); - -describe("safariAccessGranted", () => { - it.effect("only reports a successful read-only open as granted", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-safari-permission-", - }); - const jar = `${directory}/Cookies.binarycookies`; - assert.isFalse(yield* safariAccessGranted(jar)); - yield* fileSystem.writeFileString(jar, "no cookie parsing needed"); - assert.isTrue(yield* safariAccessGranted(jar)); - yield* fileSystem.remove(jar); - assert.isFalse(yield* safariAccessGranted(jar)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("does not mistake TCC denial for a grant", () => - Effect.gen(function* () { - const denied = PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "open", - cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), - }); - assert.isFalse( - yield* safariAccessGranted("/protected/Cookies.binarycookies").pipe( - Effect.provide(FileSystem.layerNoop({ open: () => Effect.fail(denied) })), - ), - ); - }), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 30bf5a7fd923..cc92563c5847 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -7,10 +7,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { ChildProcessSpawner } from "effect/process"; import * as NodeSqlite from "node:sqlite"; import type { BrowserImportPathContext } from "./Sources.ts"; @@ -18,13 +16,10 @@ import { BROWSER_IMPORT_SOURCES, chromiumProcessIsAlive, chromiumSingletonLockIsHeld, - cookieDatabaseCandidatePaths, firefoxSymlinkLockIsHeld, resolveCookieDatabase, isSourceInstalled, isSourceRunning, - isWindowsLockHeldError, - posixLockIsHeld, listSourceProfiles, sourcePathContext, windowsChromiumCookiesAreHeld, @@ -33,44 +28,6 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; -describe("Linux Chromium secret applications", () => { - it("pins the libsecret application attribute for each supported fork", () => { - assert.deepEqual( - Object.fromEntries( - BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map( - (source) => [source.id, source.linuxSecretApplication], - ), - ), - { - chrome: "chrome", - edge: "msedge", - brave: "brave", - vivaldi: "vivaldi", - opera: "opera", - helium: "chromium", - firefox: undefined, - }, - ); - }); -}); - -const platformError = (reasonTag: string): PlatformError.PlatformError => - ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; - -describe("Windows browser lock errors", () => { - it("treats sharing and lock violations reported as Busy as held", () => { - assert.isTrue(isWindowsLockHeldError(platformError("Busy"))); - }); - - it("does not treat access denied as proof of an active lock", () => { - assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied"))); - }); - - it("does not treat a missing lock file as held", () => { - assert.isFalse(isWindowsLockHeldError(platformError("NotFound"))); - }); -}); - const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); @@ -119,69 +76,6 @@ const writeFirefoxCookieDatabase = ( database.close(); }); -describe("Helium on Linux", () => { - it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: home }), - Effect.provideService(HostProcess.Platform, "linux"), - ); - const root = `${home}/.config/net.imput.helium`; - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); - yield* fileSystem.writeFileString( - `${root}/Local State`, - '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}', - ); - - assert.include(helium.platforms, "linux"); - assert.isTrue(yield* isSourceInstalled(helium, context)); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Personal", cookieCount: 3 }, - ]); - assert.isFalse(yield* isSourceRunning(helium, context)); - yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`); - assert.isTrue(yield* isSourceRunning(helium, context)); - }), - ), - ); -}); - -describe("Helium on Windows", () => { - it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () => - run( - Effect.gen(function* () { - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { - USERPROFILE: "C:\\Users\\browser-user", - LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local", - }), - Effect.provideService(HostProcess.Platform, "win32"), - ); - - assert.include(helium.platforms, "win32"); - assert.equal( - helium.userDataDirectory(context), - context.path.join( - "C:\\Users\\browser-user\\AppData\\Local", - "imput", - "Helium", - "User Data", - ), - ); - for (const source of BROWSER_IMPORT_SOURCES) { - if (source.engine === "chromium" && source.id !== "helium") { - assert.notInclude(source.platforms, "win32"); - } - } - }), - ), - ); -}); - describe("isSourceRunning", () => { it.effect("uses the held cookie database as Chromium's Windows running signal", () => run( @@ -237,32 +131,6 @@ describe("isSourceRunning", () => { }), ), ); - - it.effect.skipIf(!symlinksSupported)( - "uses the provided hostname to classify Chromium locks", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - yield* fileSystem.symlink( - "lock-owner-99999999", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); - - assert.isTrue( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcess.Hostname, "another-host"), - ), - ); - assert.isFalse( - yield* isSourceRunning(helium, paths).pipe( - Effect.provideService(HostProcess.Hostname, "lock-owner"), - ), - ); - }), - ), - ); }); describe("chromiumSingletonLockIsHeld", () => { @@ -280,12 +148,6 @@ describe("chromiumSingletonLockIsHeld", () => { }), ); - it.effect("keeps a live PID on the current host", () => - chromiumSingletonLockIsHeld("current-host-4321", "current-host", () => - Effect.succeed(true), - ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))), - ); - it.effect("keeps foreign-host and malformed targets without probing a PID", () => Effect.gen(function* () { let probes = 0; @@ -367,81 +229,9 @@ describe("isSourceInstalled", () => { }), ), ); - - it.effect("detects a Chromium 127+ install with cookies under Network/", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - - yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), - ); - - it.effect.skipIf(!symlinksSupported)( - "follows cookie database symlinks when detecting profiles", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); - - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - assert.isFalse(yield* isSourceInstalled(helium, context)); - - yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Default", name: "Default" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, context)); - }), - ), - ); }); describe("listSourceProfiles", () => { - it.effect("ignores a profile whose Cookies entry is not a file", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); - yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); - yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); - - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ - { directory: "Real", name: "Real" }, - ]); - assert.isTrue(yield* isSourceInstalled(helium, paths)); - }), - ), - ); - - it.effect("discovers profiles by their cookie database when Local State is absent", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = userDataDirectory(context); - yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); - yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(helium, context), [ - { directory: "Profile 1", name: "Profile 1" }, - ]); - }), - ), - ); - it.effect("reads the profile names the browser shows", () => run( Effect.gen(function* () { @@ -477,72 +267,6 @@ describe("listSourceProfiles", () => { ), ); - it.effect("reports nothing when no directory holds a cookie database", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(helium, context), []); - }), - ), - ); - - it.effect("drops Firefox profiles that hold no cookie database", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = firefox.userDataDirectory(context)!; - yield* fileSystem.makeDirectory(root, { recursive: true }); - yield* fileSystem.writeFileString( - `${root}/profiles.ini`, - `[Profile0] -Name=original -IsRelative=1 -Path=Profiles/abcd.default-release -Default=1 - -[Profile1] -Name=empty -IsRelative=1 -Path=Profiles/wxyz.empty -`, - ); - yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, { - recursive: true, - }); - yield* fileSystem.writeFileString( - `${root}/Profiles/abcd.default-release/cookies.sqlite`, - "db", - ); - yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" }, - ]); - }), - ), - ); - - it.effect("drops empty profiles when falling back to the Profiles/ scan", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const context = yield* withSourceHome(); - const root = firefox.userDataDirectory(context)!; - yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true }); - yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db"); - yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true }); - - assert.deepEqual(yield* listSourceProfiles(firefox, context), [ - { - directory: context.path.join("Profiles", "filled.default"), - name: "filled.default", - }, - ]); - }), - ), - ); - it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () => run( Effect.gen(function* () { @@ -559,21 +283,6 @@ Path=Profiles/wxyz.empty ), ); - it.effect("counts a profile's cookies without decrypting them", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); - yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); - - const [profile] = yield* listSourceProfiles(helium, paths); - assert.equal(profile?.cookieCount, 3); - }), - ), - ); - it.effect("falls through to the legacy database when Network/Cookies is a directory", () => run( Effect.gen(function* () { @@ -592,25 +301,6 @@ Path=Profiles/wxyz.empty }); describe("cookieDatabaseCandidatePaths", () => { - it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - const profile = context.path.join( - context.home, - "Library", - "Application Support", - "net.imput.helium", - "Profile 1", - ); - assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ - context.path.join(profile, "Network", "Cookies"), - context.path.join(profile, "Cookies"), - ]); - }), - ), - ); - it.effect("resolves the live Network/ jar over a leftover root Cookies", () => run( Effect.gen(function* () { @@ -632,25 +322,6 @@ describe("cookieDatabaseCandidatePaths", () => { }), ), ); - - it.effect("returns only cookies.sqlite for Firefox", () => - run( - Effect.gen(function* () { - const path = yield* Path.Path; - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: "/tmp/test" }), - Effect.provideService(HostProcess.Platform, "darwin"), - ); - const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); - assert.deepEqual(candidates, [ - path.join( - "/tmp/test", - "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite", - ), - ]); - }), - ), - ); }); const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; @@ -908,68 +579,6 @@ describe("isSourceRunning for Firefox", () => { ), ); - it.effect("reports not-held when no interpreter can run the fcntl probe", () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); - const lock = `${directory}/.parentlock`; - yield* fileSystem.writeFileString(lock, ""); - assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); - assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); - }), - ), - ); - - // Holds the lock with python3's fcntl, which does not exist on Windows. - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", - () => - run( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); - const context = yield* sourcePathContext.pipe( - Effect.provideService(HostProcess.Environment, { HOME: home }), - Effect.provideService(HostProcess.Platform, "darwin"), - ); - const root = firefox.userDataDirectory(context)!; - const profile = `${root}/Profiles/abcd.default-release`; - yield* fileSystem.makeDirectory(profile, { recursive: true }); - yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - const parentLock = `${profile}/.parentlock`; - yield* fileSystem.writeFileString(parentLock, ""); - - const holder = yield* spawner.spawn( - ChildProcess.make( - "python3", - [ - "-c", - "import fcntl,os,sys,time\n" + - "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + - "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + - "print('locked',flush=True)\n" + - "time.sleep(30)", - parentLock, - ], - { stdin: "ignore" }, - ), - ); - yield* holder.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.filter((line) => line.trim() === "locked"), - Stream.take(1), - Stream.runDrain, - ); - - assert.isTrue(yield* isSourceRunning(firefox, context)); - yield* holder.kill(); - }), - ), - ); - it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); @@ -1016,18 +625,6 @@ describe("isSourceRunning for Firefox", () => { ); }); -describe("Windows user-data directories", () => { - it.effect("keeps app-bound Chromium forks unsupported on win32", () => - Effect.sync(() => { - for (const source of BROWSER_IMPORT_SOURCES) { - if (source.engine === "chromium" && source.id !== "helium") { - assert.notInclude(source.platforms, "win32"); - } - } - }), - ); -}); - describe("listSourceProfiles hardening", () => { it.effect("drops profile directories that are not a single plain segment", () => run( @@ -1139,16 +736,4 @@ describe("Safari profiles", () => { }), ), ); - - it.effect("keeps Safari without profiles available", () => - run( - Effect.gen(function* () { - const context = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(safari, context), [ - { directory: ".", name: "Safari" }, - ]); - assert.isFalse(yield* isSourceInstalled(safari, context)); - }), - ), - ); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 59a89eadfa73..3e1cae508a69 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -174,7 +174,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray +const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; /** @@ -671,7 +671,7 @@ const FCNTL_PROBE_SCRIPT = * block Firefox import outright on such machines, and the SQLite snapshot * already copes with a live database's WAL, as it does for every other engine. */ -export const posixLockIsHeld = Effect.fnUntraced(function* ( +const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, ) { diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index cf92d94b6fad..52d20c6e057b 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,9 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -236,119 +234,6 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - it.effect("attaches the open-externally prompt to the window hosting the preview", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - const partition = yield* browserSessions.getPartition("scope-a"); - yield* browserSessions.getSession("scope-a"); - const requestHandler = - sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; - assert.isFunction(requestHandler); - - const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow; - const host = { isDestroyed: () => false }; - const guest = { isDestroyed: () => false, hostWebContents: host }; - fromWebContents.mockImplementation((webContents) => - webContents === host ? hostWindow : null, - ); - showMessageBox.mockReset(); - showMessageBox.mockReturnValueOnce(1); - - yield* Effect.callback((resume) => { - requestHandler( - guest, - "openExternal", - (granted: boolean) => resume(Effect.succeed(granted)), - { - externalURL: "slack://open?team=T1", - }, - ); - }); - - assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow); - }).pipe(Effect.provide(layer)), - ); - - it.effect("preserves partition scope and the platform failure chain", () => { - const nativeCause = new Error("native digest failed"); - const platformCause = PlatformError.systemError({ - _tag: "Unknown", - module: "Crypto", - method: "digest", - cause: nativeCause, - }); - const layerFailingCrypto = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: () => Effect.fail(platformCause), - }), - ); - - return Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); - - assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.equal(error.scope, "environment-a"); - assert.strictEqual(error.cause, platformCause); - assert.strictEqual(error.cause.reason.cause, nativeCause); - assert.equal( - error.message, - "Failed to derive a desktop preview browser partition for scope environment-a.", - ); - assert.notInclude(error.message, nativeCause.message); - }).pipe( - Effect.provide( - BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))), - ), - ); - }); - - it.effect("preserves session scope, partition, and the Electron failure", () => - Effect.gen(function* () { - const cause = new Error("Electron session failed"); - fromPartition.mockImplementationOnce(() => { - throw cause; - }); - const browserSessions = yield* BrowserSession.BrowserSession; - const partition = yield* browserSessions.getPartition("environment-b"); - const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); - - assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.equal(error.scope, "environment-b"); - assert.equal(error.partition, partition); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`, - ); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(layer)), - ); - - it.effect("clears storage and cache for every created session", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - yield* browserSessions.getSession("scope-a"); - yield* browserSessions.getSession("scope-b"); - - yield* browserSessions.clearCookies(); - yield* browserSessions.clearCache(); - - assert.strictEqual(sessions.size, 2); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); - assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ - { - storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], - }, - ]); - assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); - } - }).pipe(Effect.provide(layer)), - ); - it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -366,50 +251,4 @@ describe("BrowserSession", () => { assert.strictEqual(created.clearStorageData.mock.calls.length, 1); }).pipe(Effect.provide(layer)), ); - - it.effect("correlates clear failures while still attempting every session", () => - Effect.gen(function* () { - const browserSessions = yield* BrowserSession.BrowserSession; - yield* browserSessions.getSession("scope-a"); - yield* browserSessions.getSession("scope-b"); - const firstPartition = yield* browserSessions.getPartition("scope-a"); - const secondPartition = yield* browserSessions.getPartition("scope-b"); - const firstSession = sessions.get(firstPartition); - const secondSession = sessions.get(secondPartition); - assert.isDefined(firstSession); - assert.isDefined(secondSession); - - const storageCause = new Error("storage clear failed"); - secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause)); - const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); - - assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.equal(storageError.partition, secondPartition); - assert.strictEqual(storageError.cause, storageCause); - assert.equal( - storageError.message, - `Failed to clear desktop preview browser storage for partition ${secondPartition}.`, - ); - assert.notInclude(storageError.message, storageCause.message); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); - } - - const cacheCause = new Error("cache clear failed"); - firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause)); - const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); - - assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.equal(cacheError.partition, firstPartition); - assert.strictEqual(cacheError.cause, cacheCause); - assert.equal( - cacheError.message, - `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`, - ); - assert.notInclude(cacheError.message, cacheCause.message); - for (const browserSession of sessions.values()) { - assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); - } - }).pipe(Effect.provide(layer)), - ); }); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 5932db57a863..2d17a2874454 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -15,6 +15,7 @@ const makeTarget = ( }); // Each relay reply resolves after a few microtasks; one macrotask lets them all land. +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time const settle = () => new Promise((resolve) => setImmediate(resolve)); describe("CDP relay", () => { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 0d281c46f860..04dcd22cf3b2 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -82,7 +82,6 @@ describe("DesktopBrowserHost", () => { yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); debuggee.release(); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); const [, reply] = yield* Fiber.join(reader); expect(reply).toMatchObject({ type: "cdp" }); diff --git a/apps/desktop/src/preview/FaviconCapture.test.ts b/apps/desktop/src/preview/FaviconCapture.test.ts index 8446116bcd4c..2163dc002b70 100644 --- a/apps/desktop/src/preview/FaviconCapture.test.ts +++ b/apps/desktop/src/preview/FaviconCapture.test.ts @@ -140,102 +140,6 @@ function sourceJpegWithApp1Segments( return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]); } -function sourceJpegWithOrientationEntries( - width: number, - height: number, - orientations: ReadonlyArray, -): Buffer { - return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); -} - -function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer { - const exif = sourceJpegExifSegment([6], { byteOrder }); - exif[10] = alias; - exif[11] = alias; - return sourceJpegWithApp1Segments(64, 32, [exif]); -} - -function sourceJpegExifWithSubIfd(options: { - readonly rootOrientation?: number; - readonly subIfdFirst?: boolean; - readonly subIfdOrientation: number; -}): Buffer { - const rootEntries = options.rootOrientation === undefined ? 1 : 2; - const rootIfdOffset = 14; - const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; - const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4); - exif.write("Exif\0\0", 0, "binary"); - exif.write("II", 6, "ascii"); - exif.writeUInt16LE(42, 8); - exif.writeUInt32LE(8, 10); - exif.writeUInt16LE(rootEntries, rootIfdOffset); - - const writeOrientation = (offset: number, orientation: number) => { - exif.writeUInt16LE(0x0112, offset); - exif.writeUInt16LE(3, offset + 2); - exif.writeUInt32LE(1, offset + 4); - exif.writeUInt16LE(orientation, offset + 8); - }; - const writeSubIfdPointer = (offset: number) => { - exif.writeUInt16LE(0x8769, offset); - exif.writeUInt16LE(4, offset + 2); - exif.writeUInt32LE(1, offset + 4); - exif.writeUInt32LE(subIfdOffset - 6, offset + 8); - }; - - const firstRootEntryOffset = rootIfdOffset + 2; - if (options.rootOrientation === undefined) { - writeSubIfdPointer(firstRootEntryOffset); - } else if (options.subIfdFirst) { - writeSubIfdPointer(firstRootEntryOffset); - writeOrientation(firstRootEntryOffset + 12, options.rootOrientation); - } else { - writeOrientation(firstRootEntryOffset, options.rootOrientation); - writeSubIfdPointer(firstRootEntryOffset + 12); - } - - exif.writeUInt16LE(1, subIfdOffset); - writeOrientation(subIfdOffset + 2, options.subIfdOrientation); - return sourceJpegApp1Segment(exif); -} - -function sourceJpegExifWithSubIfdPointers(options: { - readonly pointerCount: number; - readonly subIfdEntries: number; -}): Buffer { - const { pointerCount, subIfdEntries } = options; - const rootIfdOffset = 14; - const rootEntries = pointerCount + 1; - const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; - const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4); - exif.write("Exif\0\0", 0, "binary"); - exif.write("II", 6, "ascii"); - exif.writeUInt16LE(42, 8); - exif.writeUInt32LE(8, 10); - exif.writeUInt16LE(rootEntries, rootIfdOffset); - for (let index = 0; index < pointerCount; index += 1) { - const entryOffset = rootIfdOffset + 2 + index * 12; - exif.writeUInt16LE(0x8769, entryOffset); - exif.writeUInt16LE(4, entryOffset + 2); - exif.writeUInt32LE(1, entryOffset + 4); - exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8); - } - const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; - exif.writeUInt16LE(0x0112, orientationOffset); - exif.writeUInt16LE(3, orientationOffset + 2); - exif.writeUInt32LE(1, orientationOffset + 4); - exif.writeUInt16LE(6, orientationOffset + 8); - - exif.writeUInt16LE(subIfdEntries, subIfdOffset); - for (let index = 0; index < subIfdEntries; index += 1) { - const entryOffset = subIfdOffset + 2 + index * 12; - exif.writeUInt16LE(1, entryOffset); - exif.writeUInt16LE(3, entryOffset + 2); - exif.writeUInt32LE(1, entryOffset + 4); - } - return sourceJpegApp1Segment(exif); -} - function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer { const rootIfdOffset = 14; const rootEntries = pointerCount + 1; @@ -288,13 +192,6 @@ function makeUnsafePng(): Buffer { return buffer; } -function sourcePng(width: number, height: number): Buffer { - const buffer = Buffer.from(SOURCE_PNG); - buffer.writeUInt32BE(width, 16); - buffer.writeUInt32BE(height, 20); - return buffer; -} - function makeUnsafeDib(): Buffer { const buffer = Buffer.alloc(40); buffer.writeUInt32LE(40, 0); @@ -328,40 +225,6 @@ function makeWebContents(options?: { }; } -const JPEG_LANDSCAPE_LAYOUT = { - draw: "context.drawImage(bitmap, 0, 8, 32, 16)", - resizeHeight: 16, - resizeWidth: 32, -} as const; -const JPEG_PORTRAIT_LAYOUT = { - draw: "context.drawImage(bitmap, 8, 0, 16, 32)", - resizeHeight: 32, - resizeWidth: 16, -} as const; - -async function expectJpegLayout( - source: Buffer, - layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT, -): Promise { - const { webContents } = makeWebContents({ - rasterize: async (code) => { - expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`); - expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`); - expect(code).toContain(layout.draw); - return PNG; - }, - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); -} - describe("selectFaviconCandidates", () => { it("filters and deduplicates before applying the candidate cap", () => { const valid = Array.from( @@ -461,35 +324,6 @@ describe("captureFavicon", () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it("cancels a rejected response body before trying the next candidate", async () => { - const cancel = vi.fn(); - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(1)); - }, - cancel, - }); - const { webContents, fetch } = makeWebContents({ - fetch: async (url) => - url.endsWith("first.png") - ? new Response(body, { status: 404 }) - : new Response(new Uint8Array(SOURCE_PNG), { - headers: { "content-type": "image/png" }, - }), - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/first.png", "https://example.com/second.png"], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - expect(cancel).toHaveBeenCalledOnce(); - expect(fetch).toHaveBeenCalledTimes(2); - }); - it("stops a pending fetch when its capture is aborted", async () => { const controller = new AbortController(); const { webContents } = makeWebContents({ @@ -510,102 +344,6 @@ describe("captureFavicon", () => { expect(await capture).toEqual({ kind: "none" }); }); - it("ends candidate fallback when the overall capture deadline expires", async () => { - const timeoutController = new AbortController(); - const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); - const { webContents, fetch } = makeWebContents({ - fetch: (url, init) => { - if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 })); - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { - once: true, - }); - }); - }, - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [ - "https://example.com/first.png", - "https://example.com/second.png", - "https://example.com/third.png", - ], - signal: new AbortController().signal, - }); - await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(fetch).toHaveBeenCalledTimes(2); - expect(timeout).toHaveBeenCalledOnce(); - } finally { - timeout.mockRestore(); - } - }); - - it("does not publish a rasterization that completes after the capture deadline", async () => { - const captureTimeoutController = new AbortController(); - const rasterTimeoutController = new AbortController(); - const timeout = vi - .spyOn(AbortSignal, "timeout") - .mockImplementation((milliseconds) => - milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal, - ); - let resolveRasterization!: (value: unknown) => void; - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ - rasterize: () => - new Promise((resolve) => { - resolveRasterization = resolve; - }), - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }); - await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce()); - captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - resolveRasterization(PNG); - } finally { - timeout.mockRestore(); - } - }); - - it("cancels a stalled response body when the capture deadline expires", async () => { - const timeoutController = new AbortController(); - const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); - const cancel = vi.fn(); - const { webContents } = makeWebContents({ - fetch: async () => - new Response( - new ReadableStream({ - cancel, - }), - { headers: { "content-type": "image/png" } }, - ), - }); - try { - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/favicon.png"], - signal: new AbortController().signal, - }); - timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(cancel).toHaveBeenCalledOnce(); - } finally { - timeout.mockRestore(); - } - }); - it("rejects and cancels an oversized streamed response", async () => { const cancel = vi.fn(); const body = new ReadableStream({ @@ -631,178 +369,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("retains bounded compatibility with common favicon formats", async () => { - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); - for (const [mime, buffer] of [ - ["image/gif", sourceGif(32, 32)], - ["image/jpeg", sourceJpeg(32, 32)], - ["image/webp", sourceWebp(32, 32)], - ["image/x-icon", sourceIco(SOURCE_PNG)], - ] as const) { - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:${mime};base64,${buffer.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - } - expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); - }); - - it.each([ - { - label: "landscape", - source: sourcePng(64, 32), - resizeWidth: 32, - resizeHeight: 16, - draw: "context.drawImage(bitmap, 0, 8, 32, 16)", - }, - { - label: "portrait", - source: sourcePng(32, 64), - resizeWidth: 16, - resizeHeight: 32, - draw: "context.drawImage(bitmap, 8, 0, 16, 32)", - }, - ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => { - const { webContents } = makeWebContents({ - rasterize: async (code) => { - expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`); - expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`); - expect(code).toContain('resizeQuality: "high"'); - expect(code).toContain(testCase.draw); - return PNG; - }, - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "captured", dataUrl: PNG }); - }); - - it.each([ - ...[1, 2, 3, 4].map((orientation) => ({ - label: `keeps stored dimensions for orientation ${orientation}`, - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, orientation), - })), - ...[5, 6, 7, 8].map((orientation) => ({ - label: `uses display dimensions for orientation ${orientation}`, - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpeg(64, 32, orientation), - })), - { - label: "uses the first separate EXIF segment when it is transposed", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpeg(64, 32, [6, 1]), - }, - { - label: "uses the first separate EXIF segment when it is untransposed", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, [1, 6]), - }, - { - label: "does not consult a later EXIF segment after an invalid orientation", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpeg(64, 32, [9, 6]), - }, - { - label: "uses a later valid orientation in the same IFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithOrientationEntries(64, 32, [9, 6]), - }, - { - label: "skips a non-EXIF APP1 segment", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("not-exif")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "skips an empty EXIF APP1 segment", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "stops after a malformed qualifying EXIF APP1 segment", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")), - sourceJpegExifSegment([6]), - ]), - }, - { - label: "ignores the EXIF padding byte", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]), - }, - { - label: "reads big-endian EXIF", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]), - }, - { - label: "matches Chromium for a nonstandard TIFF magic field", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]), - }, - { - label: "rejects a high-bit little-endian alias", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithEndianAlias(0xc9, "II"), - }, - { - label: "rejects a high-bit big-endian alias", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithEndianAlias(0xcd, "MM"), - }, - { - label: "reads an orientation from a SubIFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }), - ]), - }, - { - label: "uses a SubIFD orientation before a later root orientation", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ - rootOrientation: 1, - subIfdFirst: true, - subIfdOrientation: 6, - }), - ]), - }, - { - label: "uses a root orientation before a later SubIFD orientation", - layout: JPEG_LANDSCAPE_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }), - ]), - }, - { - label: "memoizes repeated aliases to the same SubIFD", - layout: JPEG_PORTRAIT_LAYOUT, - source: sourceJpegWithApp1Segments(64, 32, [ - sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }), - ]), - }, - ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => { - await expectJpegLayout(source, layout); - }); - it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => { const source = sourceJpegWithApp1Segments(64, 32, [ sourceJpegExifWithOverlappingSubIfds(32, 32), @@ -820,41 +386,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("rejects JPEGs with multiple frame headers before rasterization", async () => { - const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]); - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - }); - - it("rejects an unsafe PNG size before rasterization", async () => { - const buffer = makeUnsafePng(); - const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ - fetch: async () => - new Response(new Uint8Array(buffer), { - headers: { "content-type": "image/png" }, - }), - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/favicon.png"], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); - }); - it.each([ ["GIF", "image/gif", sourceGif(4096, 4096)], ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], @@ -908,85 +439,6 @@ describe("captureFavicon", () => { expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); }); - it("ignores output that is not a bounded PNG data URL", async () => { - const { webContents } = makeWebContents({ - rasterize: async () => "data:image/svg+xml;base64,c3Zn", - }); - - expect( - await captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }), - ).toEqual({ kind: "none" }); - }); - - it("waits for physical rasterization settlement after a logical timeout", async () => { - vi.useFakeTimers(); - try { - let resolveOld!: (value: unknown) => void; - let executions = 0; - const { webContents } = makeWebContents({ - rasterize: () => { - executions += 1; - return executions === 1 - ? new Promise((resolve) => { - resolveOld = resolve; - }) - : Promise.resolve(PNG); - }, - }); - const input = { - webContents, - pageUrl: "https://example.com/page", - candidates: [SOURCE_PNG_URL], - signal: new AbortController().signal, - }; - const timedOut = captureFavicon(input); - await vi.advanceTimersByTimeAsync(1_001); - expect(await timedOut).toEqual({ kind: "timed-out" }); - - const newer = captureFavicon(input); - await Promise.resolve(); - expect(executions).toBe(1); - resolveOld(PNG); - expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); - expect(executions).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it("ends candidate fallback after a rasterization timeout", async () => { - vi.useFakeTimers(); - try { - let resolveRasterization!: (value: unknown) => void; - const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ - rasterize: () => - new Promise((resolve) => { - resolveRasterization = resolve; - }), - }); - const capture = captureFavicon({ - webContents, - pageUrl: "https://example.com/page", - candidates: ["https://example.com/first.png", "https://example.com/second.png"], - signal: new AbortController().signal, - }); - - await vi.advanceTimersByTimeAsync(1_001); - - expect(await capture).toEqual({ kind: "timed-out" }); - expect(fetch).toHaveBeenCalledOnce(); - expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); - resolveRasterization(PNG); - } finally { - vi.useRealTimers(); - } - }); - it("coalesces queued rasterizations so only the latest pending capture launches", async () => { let resolveFirst!: (value: unknown) => void; let executions = 0; diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 7c7f499bc293..6cb297ff81e9 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -36,19 +36,6 @@ const makeGuest = ({ }; describe("preview guest input", () => { - it.each([undefined, false, true])( - "reloads only the guest with ignoreCache=%s", - async (ignoreCache) => { - const guest = makeGuest({ focused: false }); - await expect( - guest.send("Page.reload", ignoreCache === undefined ? {} : { ignoreCache }), - ).resolves.toEqual({}); - expect(guest.webContents.reload).toHaveBeenCalledTimes(ignoreCache ? 0 : 1); - expect(guest.webContents.reloadIgnoringCache).toHaveBeenCalledTimes(ignoreCache ? 1 : 0); - expect(guest.sendCommand).not.toHaveBeenCalled(); - }, - ); - it("types into an unfocused guest's own page, not the app's focused field", async () => { const guest = makeGuest({ focused: false }); await guest.send("Input.insertText", { text: "pairing-code" }); @@ -56,17 +43,6 @@ describe("preview guest input", () => { expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything()); }); - it("uses CDP input while the guest holds focus, where it already reaches the page", async () => { - const guest = makeGuest({ focused: true }); - await guest.send("Input.insertText", { text: "x" }); - await guest.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a" }); - expect(guest.sendCommand.mock.calls.map(([method]) => method)).toEqual([ - "Input.insertText", - "Input.dispatchKeyEvent", - ]); - expect(guest.webContents.insertText).not.toHaveBeenCalled(); - }); - it("refuses input when a cross-site frame holds the unfocused page's focus", async () => { const guest = makeGuest({ focused: false, focusedDocument: false }); await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow( @@ -74,35 +50,6 @@ describe("preview guest input", () => { ); expect(guest.webContents.insertText).not.toHaveBeenCalled(); }); - - it("clears a field with Cmd+A and Delete while unfocused, never touching the clipboard", async () => { - const guest = makeGuest({ focused: false }); - await guest.send("Input.dispatchKeyEvent", { - type: "rawKeyDown", - key: "a", - modifiers: 4, - commands: ["selectAll", "paste"], - }); - await guest.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: "Delete" }); - expect(guest.webContents.sendInputEvent.mock.calls.map(([event]) => event)).toEqual([ - { type: "rawKeyDown", keyCode: "a", modifiers: ["meta"] }, - { type: "rawKeyDown", keyCode: "Delete", modifiers: [] }, - ]); - const scripts = guest.sendCommand.mock.calls.map(([, params]) => params?.expression ?? ""); - expect(scripts.some((script) => script.includes('["selectAll"]'))).toBe(true); - expect(scripts.some((script) => script.includes("paste"))).toBe(false); - }); - - it("does not run other page commands through the guest's direct input", async () => { - const guest = makeGuest({ focused: false }); - await guest.send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1, y: 1 }); - expect(guest.sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", { - type: "mousePressed", - x: 1, - y: 1, - }); - expect(guest.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); }); describe("CDP key events as Electron input", () => { @@ -117,6 +64,7 @@ describe("CDP key events as Electron input", () => { ]); }); + // regression: #18556 agent typing in a preview tab landed in the app instead of the guest it("names arrows and function keys as Electron accelerators", () => { expect(toKeyboardInputEvents({ type: "keyUp", key: "ArrowLeft", modifiers: 3 })).toEqual([ { type: "keyUp", keyCode: "Left", modifiers: ["alt", "control"] }, diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index af45365b237d..6ded53bfaf1b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,9 +1,6 @@ -import * as NodeVM from "node:vm"; import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { - DesktopPreviewRecordingFrame, DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -35,11 +32,6 @@ import * as PreviewManager from "./Manager.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; describe("fitPictureInPictureContentSize", () => { - it("preserves the PiP content area across aspect-ratio changes", () => { - expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]); - expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([294, 523]); - }); - it("does not collapse toward the minimum size when orientation changes repeatedly", () => { const portrait = PreviewManager.fitPictureInPictureContentSize([523, 294], 9 / 16); const landscape = PreviewManager.fitPictureInPictureContentSize(portrait, 16 / 9); @@ -49,36 +41,6 @@ describe("fitPictureInPictureContentSize", () => { }); }); -describe("recordingFileExtension", () => { - it("derives the artifact extension from the recorder's actual mime type", () => { - expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4"); - expect(PreviewManager.recordingFileExtension("video/webm;codecs=vp9")).toBe("webm"); - expect(PreviewManager.recordingFileExtension("video/x-matroska")).toBe("matroska"); - }); -}); - -describe("isPreviewRefreshShortcut", () => { - const input = (overrides: Partial = {}) => - ({ - type: "keyDown", - key: "r", - meta: true, - control: false, - shift: false, - alt: false, - ...overrides, - }) as Electron.Input; - - it("recognizes the platform refresh chord without matching modified variants", () => { - expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true); - expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe( - true, - ); - expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false); - expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false); - }); -}); - describe("isPreviewEditingShortcut", () => { const input = (platform: NodeJS.Platform, key: string, overrides: Partial = {}) => ({ @@ -91,42 +53,6 @@ describe("isPreviewEditingShortcut", () => { ...overrides, }) as Electron.Input; - it.each(["darwin", "linux", "win32"] as const)( - "allows native editing chords on %s without allowing host shortcuts", - (platform) => { - for (const key of ["a", "c", "v", "x", "z", "V"]) { - expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(true); - } - const redo = - platform === "win32" ? input(platform, "y") : input(platform, "z", { shift: true }); - expect(PreviewManager.isPreviewEditingShortcut(redo, platform)).toBe(true); - expect( - PreviewManager.isPreviewEditingShortcut( - input(platform, "v", { shift: true, alt: platform === "darwin" }), - platform, - ), - ).toBe(true); - - for (const key of ["k", ",", "w", "j", "q", "+", "=", "-", "0", "r", "F12"]) { - expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(false); - } - for (const modifiers of [ - { meta: false, control: false }, - { meta: true, control: true }, - { meta: platform !== "darwin", control: platform === "darwin" }, - { alt: true }, - { shift: true, alt: platform !== "darwin" }, - ]) { - expect( - PreviewManager.isPreviewEditingShortcut(input(platform, "v", modifiers), platform), - ).toBe(false); - } - expect( - PreviewManager.isPreviewEditingShortcut(input(platform, "a", { shift: true }), platform), - ).toBe(false); - }, - ); - it("recognizes macOS Paste and Match Style when Option changes the key to a symbol", () => { const pasteAndMatchStyle = input("darwin", "◊", { code: "KeyV", alt: true, shift: true }); expect(PreviewManager.isPreviewEditingShortcut(pasteAndMatchStyle, "darwin")).toBe(true); @@ -162,15 +88,6 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); - it("opens target=_blank links as a new tab", () => { - expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( - "new-tab", - ); - expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( - "new-tab", - ); - }); - it("keeps a form POST with a body on the in-place path", () => { // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; @@ -412,44 +329,6 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; -const makeStillImage = (pixels = "fresh-png") => ({ - toPNG: () => Buffer.from(pixels), - toJPEG: () => Buffer.from(pixels), - getSize: () => ({ width: 100, height: 80 }), -}); - -const makeTestStillImageWebContents = (...args: Parameters) => { - const wc = makeTestPreviewWebContents(...args); - Object.assign(wc, { isDevToolsOpened: () => false }); - holdStillImagePaint(wc, Promise.resolve()); - return wc; -}; - -const holdStillImagePaint = (wc: TestPreviewWebContents, paint: Promise) => { - vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method) => { - if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; - if (method === "Runtime.evaluate") { - await paint; - return { result: { value: true } }; - } - return undefined; - }); -}; - -const setupStill = (manager: PreviewManager.PreviewManager["Service"], paint = Promise.resolve()) => - Effect.gen(function* () { - const image = makeStillImage(); - const capturePage = vi.fn<() => Promise>().mockResolvedValue(image); - const wc = makeTestStillImageWebContents(capturePage); - holdStillImagePaint(wc, paint); - fromId.mockReturnValue(wc); - yield* manager.createTab("tab_still"); - yield* manager.registerWebview("tab_still", wc.id); - yield* manager.setColorScheme("tab_still", "system"); - return { image, capturePage, wc, capture: manager.captureScreenshot("tab_still") }; - }); - const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -497,6 +376,7 @@ const makeSourcePng = (width = 1, height = 1): Buffer => { }; const makeFaviconWebContents = (options?: { + readonly changes?: ChangeSignal; readonly fetch?: (url: string, init?: RequestInit) => Promise; readonly id?: number; readonly rasterize?: (code: string) => Promise; @@ -507,16 +387,25 @@ const makeFaviconWebContents = (options?: { let currentUrl = options?.url ?? "http://localhost:3200/"; let destroyed = false; let loading = false; - const fetch = vi.fn( + const fetchImpl = options?.fetch ?? - (async () => - new Response(new Uint8Array(sourcePng), { - headers: { "content-type": "image/png" }, - })), - ); + (async () => + new Response(new Uint8Array(sourcePng), { + headers: { "content-type": "image/png" }, + })); + const fetch = vi.fn((url: string, init?: RequestInit) => { + const response = fetchImpl(url, init); + options?.changes?.notify(); + return response; + }); const executeJavaScriptInIsolatedWorld = vi.fn( - async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => - options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON, + (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => { + const result = options?.rasterize + ? options.rasterize(scripts[0]?.code ?? "") + : Promise.resolve(TEST_FAVICON); + options?.changes?.notify(); + return result; + }, ); const reload = vi.fn(); const loadURL = vi.fn(async (url: string) => { @@ -581,10 +470,33 @@ const makeFaviconWebContents = (options?: { }; }; -const settle = function* (until: () => boolean) { - for (let attempt = 0; attempt < 30 && !until(); attempt++) { - yield* Effect.promise(() => Promise.resolve()); - } +// Electron mocks and state subscribers call `notify`, so a wait re-checks its +// predicate exactly when the state it reads changes. +const makeChangeSignal = () => { + const waiters = new Set<() => void>(); + return { + notify: () => { + for (const wake of waiters) wake(); + }, + until: (predicate: () => boolean) => + Effect.callback((resume) => { + if (predicate()) return resume(Effect.void); + const wake = () => { + if (!predicate()) return; + waiters.delete(wake); + resume(Effect.void); + }; + waiters.add(wake); + return Effect.sync(() => void waiters.delete(wake)); + }), + }; +}; +type ChangeSignal = ReturnType; + +// Favicon captures run detached from the Electron listener, so asserting that +// one published nothing can only give its promise chain a fixed number of turns. +const flushMicrotasks = function* () { + for (let turn = 0; turn < 30; turn++) yield* Effect.promise(() => Promise.resolve()); }; const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { @@ -644,154 +556,6 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); - effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const opened: Array<{ tabId: string; url: string; background: boolean }> = []; - yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); - yield* manager.createTab("tab_links"); - yield* manager.registerWebview("tab_links", 42); - const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as - | ReturnType - | undefined; - const handler = openHandler?.mock.lastCall?.[0] as ( - details: Partial, - ) => { action: string }; - - expect( - handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }), - ).toEqual({ action: "deny" }); - expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({ - action: "deny", - }); - handler({ url: "https://github.com/issues/2", disposition: "background-tab" }); - yield* settle(() => opened.length === 2); - - expect(preview.loadURL).not.toHaveBeenCalled(); - expect(opened).toEqual([ - { tabId: "tab_links", url: "https://github.com/issues/1", background: false }, - { tabId: "tab_links", url: "https://github.com/issues/2", background: true }, - ]); - }), - ), - ); - - effectIt.effect("keeps preview shortcuts out of the host window", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - const sendInputEvent = vi.fn(); - const hostWebContents = { sendInputEvent, isDestroyed: () => false }; - Object.assign(preview.webContents, { hostWebContents }); - fromId.mockReturnValue(preview.webContents); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: hostWebContents, - } as never); - yield* manager.createTab("tab_keys"); - yield* manager.registerWebview("tab_keys", 42); - - expect( - (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenCalledWith(true); - const beforeInput = preview.listeners.get("before-input-event")!; - for (const control of [false, true]) { - for (const key of ["k", ",", "w", "j", "q", "+"]) { - for (const type of ["keyDown", "keyUp"]) { - const preventDefault = vi.fn(); - beforeInput( - { preventDefault } as never, - { type, key, meta: !control, control, shift: key === "j", alt: false } as never, - ); - yield* Effect.yieldNow; - expect(preventDefault).not.toHaveBeenCalled(); - expect( - (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenLastCalledWith(true); - } - } - } - expect(sendInputEvent).not.toHaveBeenCalled(); - - const preventDefault = vi.fn(); - beforeInput( - { preventDefault } as never, - { - type: "keyDown", - key: "r", - meta: true, - control: false, - shift: false, - alt: false, - } as never, - ); - yield* Effect.yieldNow; - expect(preventDefault).toHaveBeenCalledOnce(); - expect(preview.reload).toHaveBeenCalledOnce(); - expect(sendInputEvent).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect.each([ - ["mod+shift+t", "view.reopenClosed"], - ["ctrl+alt+u", "sidebar.toggle"], - ] as const)( - "forwards only the configured command from the focused guest: %s", - ([chord, command]) => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - const send = vi.fn(); - Object.assign(preview.webContents, { - hostWebContents: { isDestroyed: () => false, send }, - }); - fromId.mockReturnValue(preview.webContents); - yield* manager.createTab("tab_reopen"); - yield* manager.registerWebview("tab_reopen", 42); - const shortcut = parseKeybindingShortcut(chord)!; - yield* manager.setForwardedShortcuts([{ command, shortcut }]); - const beforeInput = preview.listeners.get("before-input-event")!; - const input = { - type: "keyDown", - key: shortcut.key.toUpperCase(), - meta: shortcut.metaKey || shortcut.modKey, - control: shortcut.ctrlKey, - shift: shortcut.shiftKey, - alt: shortcut.altKey, - }; - const preventDefault = vi.fn(); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command); - for (const overrides of [ - { isAutoRepeat: true }, - { type: "keyUp" }, - { shift: !input.shift }, - { key: "x" }, - ]) { - preventDefault.mockClear(); - beforeInput({ preventDefault } as never, { ...input, ...overrides } as never); - expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0); - expect(send).toHaveBeenCalledOnce(); - } - vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(false); - preventDefault.mockClear(); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).not.toHaveBeenCalled(); - expect(send).toHaveBeenCalledOnce(); - vi.mocked((preview.webContents as Electron.WebContents).isFocused).mockReturnValue(true); - yield* manager.setForwardedShortcuts([]); - beforeInput({ preventDefault } as never, input as never); - expect(preventDefault).not.toHaveBeenCalled(); - expect(send).toHaveBeenCalledOnce(); - }), - ), - ); - effectIt.effect.each([ ["linux", "ctrl+alt+[", "[", "BracketLeft", false], ["win32", "ctrl+alt+8", "[", "Digit8", false], @@ -836,52 +600,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - yield* manager.createTab("tab_editing"); - yield* manager.registerWebview("tab_editing", 42); - - const popup = makeFaviconWebContents({ id: 43 }); - preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never); - expect( - (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts, - ).toHaveBeenCalledWith(true); - - for (const browser of [preview, popup]) { - const contents = browser.webContents as Electron.WebContents; - const beforeInput = browser.listeners.get("before-input-event")!; - const preventDefault = vi.fn(); - const input = { - type: "keyDown", - key: "v", - meta: true, - control: false, - shift: false, - alt: false, - }; - beforeInput({ preventDefault } as never, input as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - beforeInput( - { preventDefault } as never, - { ...input, type: "keyUp", key: "Meta", meta: false } as never, - ); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - - beforeInput({ preventDefault } as never, { ...input, key: "w" } as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - - vi.mocked(contents.isFocused).mockReturnValue(false); - beforeInput({ preventDefault } as never, input as never); - expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - expect(preventDefault).not.toHaveBeenCalled(); - } - }), - ), - ); - effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { @@ -937,32 +655,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("rejects a destroyed webview during registration", () => - withManager((manager) => - Effect.gen(function* () { - const getType = vi.fn(() => "webview" as const); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => true, - getType, - } as never); - yield* manager.createTab("tab_destroyed_registration"); - - const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42)); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewWebContentsNotFoundError", - tabId: "tab_destroyed_registration", - webContentsId: 42, - }); - } - expect(getType).not.toHaveBeenCalled(); - }), - ), - ); - effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { @@ -1075,36 +767,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("detaches a destroyed webview instead of navigating it", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_destroyed_navigation"); - yield* manager.registerWebview("tab_destroyed_navigation", 42); - yield* manager.setColorScheme("tab_destroyed_navigation", "dark"); - preview.setDestroyed(true); - - yield* manager.navigate("tab_destroyed_navigation", "https://example.com/"); - - expect(preview.loadURL).not.toHaveBeenCalled(); - expect(preview.reload).not.toHaveBeenCalled(); - expect(preview.off).toHaveBeenCalled(); - expect(preview.debuggerOff).toHaveBeenCalled(); - expect(states.at(-1)).toMatchObject({ - webContentsId: null, - navStatus: { kind: "Loading", url: "https://example.com/" }, - }); - }), - ), - ); - effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { @@ -1211,46 +873,16 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("publishes a canonical favicon origin while the page is loading", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents({ - url: `http://localhost:3200/${"x".repeat(3_000)}`, - }); - preview.setLoading(true); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_loading"); - yield* manager.registerWebview("tab_favicon_loading", 42); - - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - expect(states.at(-1)?.favicon).toMatchObject({ - dataUrl: TEST_FAVICON, - pageUrl: "http://localhost:3200", - }); - expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number)); - }), - ), - ); - effectIt.effect("shares an identical in-flight event and lets a changed event win", () => withManager((manager) => Effect.gen(function* () { + const changes = makeChangeSignal(); let resolveFirst!: (response: Response) => void; const firstResponse = new Promise((resolve) => { resolveFirst = resolve; }); const preview = makeFaviconWebContents({ + changes, fetch: (url) => url.endsWith("first.png") ? firstResponse @@ -1265,6 +897,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_latest"); @@ -1273,15 +906,15 @@ describe("PreviewManager", () => { const faviconUpdated = preview.listeners.get("page-favicon-updated")!; faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never); - yield* settle(() => preview.fetch.mock.calls.length === 1); + yield* changes.until(() => preview.fetch.mock.calls.length === 1); faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* changes.until(() => states.at(-1)?.favicon !== undefined); resolveFirst( new Response(new Uint8Array(makeSourcePng()), { headers: { "content-type": "image/png" }, }), ); - yield* settle(() => false); + yield* flushMicrotasks(); expect(preview.fetch).toHaveBeenCalledTimes(2); expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1); @@ -1289,41 +922,13 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("allows an identical retry after an undecodable capture", () => - withManager((manager) => - Effect.gen(function* () { - let rasterizations = 0; - const preview = makeFaviconWebContents({ - rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON), - }); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_retry"); - yield* manager.registerWebview("tab_favicon_retry", 42); - const faviconUpdated = preview.listeners.get("page-favicon-updated")!; - - faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); - yield* settle(() => rasterizations === 1); - yield* settle(() => false); - faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - expect(rasterizations).toBe(2); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - effectIt.effect("does not publish a capture invalidated by navigation", () => withManager((manager) => Effect.gen(function* () { + const changes = makeChangeSignal(); let resolveFetch!: (response: Response) => void; const preview = makeFaviconWebContents({ + changes, fetch: () => new Promise((resolve) => { resolveFetch = resolve; @@ -1334,6 +939,7 @@ describe("PreviewManager", () => { yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); yield* manager.createTab("tab_favicon_navigation"); @@ -1342,7 +948,7 @@ describe("PreviewManager", () => { {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => preview.fetch.mock.calls.length === 1); + yield* changes.until(() => preview.fetch.mock.calls.length === 1); preview.listeners.get("did-start-navigation")?.({ isMainFrame: true, isSameDocument: false, @@ -1353,3072 +959,1098 @@ describe("PreviewManager", () => { headers: { "content-type": "image/png" }, }), ); - yield* settle(() => false); + yield* flushMicrotasks(); expect(states.some((state) => state.favicon !== undefined)).toBe(false); }), ), ); - effectIt.effect("retains a favicon when reloading the current URL without a new event", () => + effectIt.effect("ignores an old capture that completes after webview replacement", () => withManager((manager) => Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); + const changes = makeChangeSignal(); + let resolveFetch!: (response: Response) => void; + const initial = makeFaviconWebContents({ + changes, + id: 42, + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const replacement = makeFaviconWebContents({ changes, id: 43 }); + fromId.mockImplementation((id?: number) => + id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, + ); const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); - yield* manager.createTab("tab_favicon_reload"); - yield* manager.registerWebview("tab_favicon_reload", 42); - preview.listeners.get("page-favicon-updated")?.( + yield* manager.createTab("tab_favicon_late_replace"); + yield* manager.registerWebview("tab_favicon_late_replace", 42); + initial.listeners.get("page-favicon-updated")?.( {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/"); - - expect(preview.reload).toHaveBeenCalledOnce(); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); + yield* changes.until(() => initial.fetch.mock.calls.length === 1); - effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); + yield* manager.registerWebview("tab_favicon_late_replace", 43); + resolveFetch( + new Response(new Uint8Array(makeSourcePng()), { + headers: { "content-type": "image/png" }, }), ); - yield* manager.createTab("tab_favicon_origin"); - yield* manager.registerWebview("tab_favicon_origin", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - preview.setUrl("https://example.com/"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); + yield* flushMicrotasks(); - expect(states.at(-1)?.favicon).toBeUndefined(); + expect(states.at(-1)?.webContentsId).toBe(43); + expect( + states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), + ).toBe(false); }), ), ); - effectIt.effect( - "retains the previous document icon across a failed cross-origin navigation", - () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_failed_origin"); - yield* manager.registerWebview("tab_favicon_failed_origin", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - preview.listeners.get("did-fail-load")?.( - {} as never, - -105 as never, - "Name not resolved" as never, - "https://unreachable.example/" as never, - true as never, - ); - yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed"); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); - }), - ), - ); - - effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () => + effectIt.effect("treats a reused WebContents id as a new attachment", () => withManager((manager) => Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); + const changes = makeChangeSignal(); + const initial = makeFaviconWebContents({ changes, id: 42 }); + const replacement = makeFaviconWebContents({ changes, id: 42 }); + let active = initial.webContents; + fromId.mockImplementation(() => active); const states: PreviewManager.PreviewTabState[] = []; yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); + changes.notify(); }), ); - yield* manager.createTab("tab_favicon_blank"); - yield* manager.registerWebview("tab_favicon_blank", 42); - preview.listeners.get("page-favicon-updated")?.( + yield* manager.createTab("tab_favicon_reused_id"); + yield* manager.registerWebview("tab_favicon_reused_id", 42); + initial.listeners.get("page-favicon-updated")?.( {} as never, ["http://localhost:3200/favicon.png"] as never, ); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* changes.until(() => states.at(-1)?.favicon !== undefined); - preview.setUrl("about:blank"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Idle"); - expect(states.at(-1)?.favicon).toBeUndefined(); + active = replacement.webContents; + yield* manager.registerWebview("tab_favicon_reused_id", 42); - preview.setUrl("http://localhost:3200/"); - preview.listeners.get("did-navigate")?.({} as never); - yield* settle(() => states.at(-1)?.navStatus.kind === "Success"); expect(states.at(-1)?.favicon).toBeUndefined(); + expect(initial.off).toHaveBeenCalled(); + expect(replacement.listeners.has("page-favicon-updated")).toBe(true); }), ), ); - effectIt.effect("clears a published favicon when a replacement webview attaches", () => - withManager((manager) => - Effect.gen(function* () { - const initial = makeFaviconWebContents({ id: 42 }); - const replacement = makeFaviconWebContents({ id: 43 }); - fromId.mockImplementation((id?: number) => { - if (id === 42) return initial.webContents; - if (id === 43) return replacement.webContents; - return null; - }); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_replace"); - yield* manager.registerWebview("tab_favicon_replace", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); + const makeAttachingGuest = (id: number) => { + const listeners = new Map void>(); + const attach = vi.fn(); + const detach = vi.fn(); + const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( + async () => undefined, + ); + const detachListeners = new Set<() => void>(); + let chromiumDetaches = 0; + let destroyed = false; + const wc = { + id, + isDestroyed: () => destroyed, + isDevToolsOpened: () => { + // Electron throws from native methods once a WebContents is destroyed. + if (destroyed) throw new TypeError("Object has been destroyed"); + return false; + }, + getType: () => "webview", + getURL: () => "http://localhost:5173/README.md", + getTitle: () => "README.md", + isLoadingMainFrame: () => true, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + once: (event: string, listener: () => void) => { + listeners.set(event, listener); + }, + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches, + attach, + detach, + sendCommand, + on: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.add(listener); + }, + off: (event: string, listener: () => void) => { + if (event === "detach") detachListeners.delete(listener); + }, + }, + }; + /** Chromium dropping the debugger without a detach() call. */ + const chromiumDetach = () => { + chromiumDetaches += 1; + // Snapshot like an EventEmitter, so a listener added during emit waits for the next one. + for (const listener of Array.from(detachListeners)) listener(); + }; + return { + wc: wc as unknown as Electron.WebContents, + attach, + detach, + sendCommand, + chromiumDetach, + destroy: () => { + destroyed = true; + listeners.get("destroyed")?.(); + }, + }; + }; - yield* manager.registerWebview("tab_favicon_replace", 43); + effectIt.effect("reattaches a debugger that Chromium dropped on its own", () => + withManager((manager) => + Effect.gen(function* () { + const guest = makeAttachingGuest(48); + fromId.mockReturnValue(guest.wc); + yield* manager.createTab("tab_detached"); + yield* manager.registerWebview("tab_detached", 48); + yield* Effect.yieldNow; + expect(guest.attach).toHaveBeenCalledTimes(1); - expect(states.at(-1)?.webContentsId).toBe(43); - expect(states.at(-1)?.favicon).toBeUndefined(); + guest.chromiumDetach(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + expect(guest.attach).toHaveBeenCalledTimes(2); }), ), ); - effectIt.effect("ignores an old capture that completes after webview replacement", () => + effectIt.effect("skips a guest destroyed while another guest's session opens", () => withManager((manager) => Effect.gen(function* () { - let resolveFetch!: (response: Response) => void; - const initial = makeFaviconWebContents({ - id: 42, - fetch: () => - new Promise((resolve) => { - resolveFetch = resolve; - }), + const slow = makeAttachingGuest(46); + let releaseSlow = () => {}; + const slowCommand = new Promise((resolve) => { + releaseSlow = resolve; }); - const replacement = makeFaviconWebContents({ id: 43 }); - fromId.mockImplementation((id?: number) => - id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null, - ); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_favicon_late_replace"); - yield* manager.registerWebview("tab_favicon_late_replace", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => initial.fetch.mock.calls.length === 1); + slow.sendCommand.mockImplementation(() => slowCommand); + const queued = makeAttachingGuest(47); - yield* manager.registerWebview("tab_favicon_late_replace", 43); - resolveFetch( - new Response(new Uint8Array(makeSourcePng()), { - headers: { "content-type": "image/png" }, - }), - ); - yield* settle(() => false); + const slowFiber = yield* manager + .prepareWebview(slow.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + const queuedFiber = yield* manager + .prepareWebview(queued.wc) + .pipe(Effect.forkChild({ startImmediately: true })); + queued.destroy(); + releaseSlow(); - expect(states.at(-1)?.webContentsId).toBe(43); - expect( - states.some((state) => state.webContentsId === 43 && state.favicon !== undefined), - ).toBe(false); + expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); + expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); + expect(slow.attach).toHaveBeenCalledTimes(1); + expect(queued.attach).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("treats a reused WebContents id as a new attachment", () => + const makeAudioWebContents = (id: number) => { + const listeners = new Map void>(); + const setAudioMuted = vi.fn(); + let audible = false; + let audibleAfterFirstRead = false; + let audibleReads = 0; + return { + setAudioMuted, + emitAudioState: (next: boolean) => { + audible = next; + listeners.get("audio-state-changed")?.({ audible: next } as never); + }, + /** + * Starts playing between the attach-time read and the post-attach + * reconcile, without a delivered event — the window in which + * audio-state-changed fires against a guest the tab does not own yet. + */ + startPlayingAfterFirstRead: () => { + audibleAfterFirstRead = true; + }, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoadingMainFrame: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted, + isCurrentlyAudible: () => { + audibleReads += 1; + if (audibleAfterFirstRead && audibleReads > 1) return true; + return audible; + }, + loadURL: vi.fn(async () => undefined), + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + off: vi.fn((event: string) => { + listeners.delete(event); + }), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + + effectIt.effect("reconciles audibility that changed while the guest attached", () => withManager((manager) => Effect.gen(function* () { - const initial = makeFaviconWebContents({ id: 42 }); - const replacement = makeFaviconWebContents({ id: 42 }); - let active = initial.webContents; - fromId.mockImplementation(() => active); + const guest = makeAudioWebContents(42); + guest.startPlayingAfterFirstRead(); + fromId.mockReturnValue(guest.wc); const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); }), ); - yield* manager.createTab("tab_favicon_reused_id"); - yield* manager.registerWebview("tab_favicon_reused_id", 42); - initial.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); - - active = replacement.webContents; - yield* manager.registerWebview("tab_favicon_reused_id", 42); + yield* manager.createTab("tab_audio_window"); + yield* manager.registerWebview("tab_audio_window", 42); + yield* Effect.yieldNow; - expect(states.at(-1)?.favicon).toBeUndefined(); - expect(initial.off).toHaveBeenCalled(); - expect(replacement.listeners.has("page-favicon-updated")).toBe(true); + // audio-state-changed for this transition was dropped: it fired before + // the tab owned the guest. Without a post-attach reconcile the icon + // stays wrong until the next real transition, which may never come. + expect(states.at(-1)?.audible).toBe(true); }), ), ); - effectIt.effect("preserves a favicon when the active attachment registers again", () => + effectIt.effect("ignores audio state from a replaced guest", () => withManager((manager) => Effect.gen(function* () { - const preview = makeFaviconWebContents(); - fromId.mockReturnValue(preview.webContents); + const first = makeAudioWebContents(42); + fromId.mockReturnValue(first.wc); const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => Effect.sync(() => { states.push(state); }), ); - yield* manager.createTab("tab_favicon_reregister"); - yield* manager.registerWebview("tab_favicon_reregister", 42); - preview.listeners.get("page-favicon-updated")?.( - {} as never, - ["http://localhost:3200/favicon.png"] as never, - ); - yield* settle(() => states.at(-1)?.favicon !== undefined); + yield* manager.createTab("tab_audio_stale"); + yield* manager.registerWebview("tab_audio_stale", 42); + yield* Effect.yieldNow; + + const replacement = makeAudioWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_audio_stale", 43); + yield* Effect.yieldNow; - yield* manager.registerWebview("tab_favicon_reregister", 42); + const publishedBefore = states.length; + first.emitAudioState(true); + yield* Effect.yieldNow; - expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.audible).toBe(false); }), ), ); - // The guest reports whatever zoom level Chromium handed it from the app - // window, so the tab's own zoom is the source of truth in both directions: - // asserted onto every guest, never read back off one. - effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => + effectIt.effect("blocks late webview and capture starts during tab close", () => withManager((manager) => Effect.gen(function* () { - let effectiveZoom = 0.9; - let zoomReadable = true; - let url = "https://example.com"; - const listeners = new Map void>(); - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => { - if (!zoomReadable) throw new Error("zoom unavailable"); - return effectiveZoom; - }, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const states: PreviewManager.PreviewTabState[] = []; + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("close-race-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const firstWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); + const replacementListenerSpies = replacementWebContents as unknown as { + readonly on: ReturnType; + readonly off: ReturnType; + readonly ipc: { readonly off: ReturnType }; + }; + fromId.mockImplementation((id) => { + if (id === 42) return firstWebContents; + if (id === 43) return replacementWebContents; + return null; + }); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_close_register_race"); + yield* manager.registerWebview("tab_close_register_race", 42); + yield* manager.openPictureInPicture("tab_close_register_race"); + const closeCleanupPaused = yield* Deferred.make(); + const continueCloseCleanup = yield* Deferred.make(); yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), + !state.pictureInPicture && state.webContentsId === 42 + ? Deferred.succeed(closeCleanupPaused, undefined).pipe( + Effect.andThen(Deferred.await(continueCloseCleanup)), + ) + : Effect.void, ); - yield* manager.createTab("tab_zoom"); - yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(1); - expect(setZoomFactor).toHaveBeenCalledWith(1); - - effectiveZoom = 0.8; - url = "https://example.com/after-app-zoom"; - listeners.get("did-navigate")?.(); + const closeFiber = yield* manager + .closeTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(closeCleanupPaused); + const recreateFiber = yield* manager + .createTab("tab_close_register_race") + .pipe(Effect.forkChild({ startImmediately: true })); + const registrationFiber = yield* manager + .registerWebview("tab_close_register_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + yield* manager.closeTab("tab_close_register_race"); + const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + yield* Deferred.succeed(continueCloseCleanup, undefined); + yield* Fiber.join(closeFiber); + const recreated = yield* Fiber.join(recreateFiber); + const registrationExit = yield* Fiber.await(registrationFiber); - expect(states.at(-1)?.navStatus).toEqual({ - kind: "Success", - url, - title: "Example", - }); - expect(states.at(-1)?.zoomFactor).toBe(1); + for (const exit of [registrationExit, recordingExit] as ReadonlyArray< + Exit.Exit + >) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) continue; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewTabNotFoundError", + tabId: "tab_close_register_race", + }); + } + expect(replacementListenerSpies.on).not.toHaveBeenCalled(); + expect(replacementListenerSpies.off).not.toHaveBeenCalled(); + expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); + expect(capturePage).toHaveBeenCalledOnce(); + expect(recreated.webContentsId).toBeNull(); + }), + ), + ); - yield* manager.zoomIn("tab_zoom"); - expect(setZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.at(-1)?.zoomFactor).toBe(1.1); + effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => + withManager((manager) => + Effect.gen(function* () { + const firstPng = Buffer.from("first-preview-png"); + const secondPng = Buffer.from("second-preview-png"); + const pngImage = (png: Buffer) => ({ + toPNG: () => png, + toJPEG: () => png, + getSize: () => ({ width: 1280, height: 720 }), + }); + const capturePage = vi + .fn<() => Promise>>() + .mockResolvedValueOnce(pngImage(firstPng)) + .mockResolvedValueOnce(pngImage(secondPng)); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + // it.effect keeps TestClock fixed until explicitly advanced. + const first = yield* manager.captureScreenshot("tab_1"); + const second = yield* manager.captureScreenshot("tab_1"); - zoomReadable = false; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; + expect(first.createdAt).toBe(second.createdAt); + expect(first.id).not.toBe(second.id); + expect(first.path).not.toBe(second.path); + expect(first.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/); + expect(second.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/); + expect(writeFile.mock.calls).toEqual([ + [first.path, firstPng], + [second.path, secondPng], + ]); + }), + ), + ); - expect(states.at(-1)?.zoomFactor).toBe(1.1); + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => + withManager((manager) => + Effect.gen(function* () { + const setBackgroundThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const host = makeTestHostWebContents(); + const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); + const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); + const webContentsById = new Map([ + [41, firstWebContents], + [42, secondWebContents], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); - const replacementSetZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 43, + yield* manager.createTab("tab_capture_throttling_1"); + yield* manager.createTab("tab_capture_throttling_2"); + yield* manager.registerWebview("tab_capture_throttling_1", 41); + yield* manager.registerWebview("tab_capture_throttling_2", 42); + yield* manager.setMainWindow({ isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: replacementSetZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, + once: vi.fn(), + webContents: { setBackgroundThrottling }, } as never); - yield* manager.registerWebview("tab_zoom", 43); + yield* manager.startRecording("tab_capture_throttling_1"); + host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); + yield* manager.startRecording("tab_capture_throttling_2"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + + yield* manager.stopRecording("tab_capture_throttling_1"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.at(-1)?.zoomFactor).toBe(1.1); + yield* manager.stopRecording("tab_capture_throttling_2"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); }), ), ); - // Zooming the app UI pushes the window's zoom level onto every guest, so the - // preview has to be put back at the zoom the user gave it. - effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + effectIt.effect("does not commit failed starts and retries throttle restoration", () => withManager((manager) => Effect.gen(function* () { - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, + const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + + yield* manager.createTab("tab_capture_throttling_failure"); + yield* manager.registerWebview("tab_capture_throttling_failure", 42); + yield* manager.setMainWindow({ isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, + once: vi.fn(), + webContents: { setBackgroundThrottling }, } as never); - yield* manager.createTab("tab_reapply"); - yield* manager.registerWebview("tab_reapply", 42); - yield* manager.zoomIn("tab_reapply"); - setZoomFactor.mockClear(); + setBackgroundThrottling.mockImplementationOnce(() => { + throw new Error("start throttling update failed"); + }); + const failedStart = yield* Effect.exit( + manager.startRecording("tab_capture_throttling_failure"), + ); + expect(Exit.isFailure(failedStart)).toBe(true); + + yield* manager.startRecording("tab_capture_throttling_failure"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false]]); - yield* manager.reapplyZoom(); + setBackgroundThrottling.mockImplementationOnce(() => { + throw new Error("stop throttling update failed"); + }); + yield* manager.stopRecording("tab_capture_throttling_failure"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true], [true]]); - expect(setZoomFactor).toHaveBeenCalledTimes(1); - expect(setZoomFactor).toHaveBeenCalledWith(1.1); + yield* manager.startRecording("tab_capture_throttling_failure"); + yield* manager.stopRecording("tab_capture_throttling_failure"); + expect(setBackgroundThrottling.mock.calls).toEqual([ + [false], + [false], + [true], + [true], + [false], + [true], + ]); }), ), ); - effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + effectIt.effect("keeps frame capture closed when a replacement is destroyed during cleanup", () => withManager((manager) => Effect.gen(function* () { - const setZoomFactor = vi.fn(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor, - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, + const windowEvents = new Map void>(); + const host = makeTestHostWebContents(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const guest = makeTestPreviewWebContents(capturePage, 42, host); + fromId.mockReturnValue(guest); + yield* manager.createTab("tab_shutdown"); + yield* manager.registerWebview("tab_shutdown", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: (event: string, listener: () => void) => windowEvents.set(event, listener), + webContents: { setBackgroundThrottling: vi.fn() }, } as never); - const states: PreviewManager.PreviewTabState[] = []; + yield* manager.startRecording("tab_shutdown"); + const pip = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pip.pictureInPictureWindow; + }); + yield* manager.openPictureInPicture("tab_shutdown"); + const capturesBeforeClose = capturePage.mock.calls.length; + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), + state.pictureInPicture + ? Effect.void + : Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + ), ); - yield* manager.createTab("tab_reregister_zoom"); - yield* manager.registerWebview("tab_reregister_zoom", 42); - yield* manager.zoomIn("tab_reregister_zoom"); - setZoomFactor.mockClear(); - const publishedBefore = states.length; - - yield* manager.registerWebview("tab_reregister_zoom", 42); + windowEvents.get("closed")!(); + yield* Deferred.await(cleanupStarted); + let destroyed = false; + const replacement = yield* manager + .setMainWindow({ + isDestroyed: () => destroyed, + once: vi.fn(), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never) + .pipe(Effect.forkChild({ startImmediately: true })); + try { + expect(replacement.pollUnsafe()).toBeUndefined(); + destroyed = true; + } finally { + yield* Deferred.succeed(releaseCleanup, undefined); + } + yield* Fiber.join(replacement); - expect(setZoomFactor).toHaveBeenCalledWith(1.1); - expect(states.length).toBe(publishedBefore); - expect(states.at(-1)?.zoomFactor).toBe(1.1); + const restart = yield* Effect.exit(manager.startRecording("tab_shutdown")); + expect(Exit.isFailure(restart)).toBe(true); + if (Exit.isFailure(restart)) { + expect(Option.getOrThrow(Cause.findErrorOption(restart.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_shutdown", + }); + } + expect(capturePage).toHaveBeenCalledTimes(capturesBeforeClose); + expect(pip.pictureInPictureWindow.isDestroyed()).toBe(true); + expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); }), ), ); - effectIt.effect("re-applies opaque base and color scheme across webview swaps", () => + effectIt.effect("ignores close events from replaced main windows", () => withManager((manager) => Effect.gen(function* () { - const makeWebContents = (id: number) => { - const sendCommand = vi.fn(async () => undefined); - return { - sendCommand, - wc: { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - } as never, - }; - }; - const first = makeWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; + let closeFirstWindow: (() => void) | undefined; + const firstWindowThrottling = vi.fn(); + const replacementWindowThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); + yield* manager.createTab("tab_replaced_window_close"); + yield* manager.registerWebview("tab_replaced_window_close", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeFirstWindow = listener; }), - ); - yield* manager.createTab("tab_scheme"); - yield* manager.registerWebview("tab_scheme", 42); - yield* Effect.yieldNow; - - // Guests start with a transparent base; dark-scheme pages need an opaque - // one or Chromium skips their dark canvas. - const opaqueBase = { - color: { r: 255, g: 255, b: 255, a: 1 }, - }; - expect(first.sendCommand).toHaveBeenCalledWith( - "Emulation.setDefaultBackgroundColorOverride", - opaqueBase, - ); - - yield* manager.setColorScheme("tab_scheme", "dark"); - - expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); - - const replacement = makeWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_scheme", 43); - yield* Effect.yieldNow; - - expect(replacement.sendCommand).toHaveBeenCalledWith( - "Emulation.setDefaultBackgroundColorOverride", - opaqueBase, - ); - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - expect(states.at(-1)?.colorScheme).toBe("dark"); - - yield* manager.setColorScheme("tab_scheme", "system"); - - expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "" }], - }); - expect(states.at(-1)?.colorScheme).toBe("system"); - }), - ), - ); - - const makeAttachingGuest = (id: number) => { - const listeners = new Map void>(); - const attach = vi.fn(); - const detach = vi.fn(); - const sendCommand = vi.fn<(method: string, params?: unknown) => Promise>( - async () => undefined, - ); - const detachListeners = new Set<() => void>(); - let chromiumDetaches = 0; - let destroyed = false; - const wc = { - id, - isDestroyed: () => destroyed, - isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. - if (destroyed) throw new TypeError("Object has been destroyed"); - return false; - }, - getType: () => "webview", - getURL: () => "http://localhost:5173/README.md", - getTitle: () => "README.md", - isLoadingMainFrame: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - once: (event: string, listener: () => void) => { - listeners.set(event, listener); - }, - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => attach.mock.calls.length > detach.mock.calls.length + chromiumDetaches, - attach, - detach, - sendCommand, - on: (event: string, listener: () => void) => { - if (event === "detach") detachListeners.add(listener); - }, - off: (event: string, listener: () => void) => { - if (event === "detach") detachListeners.delete(listener); - }, - }, - }; - /** Chromium dropping the debugger without a detach() call. */ - const chromiumDetach = () => { - chromiumDetaches += 1; - // Snapshot like an EventEmitter, so a listener added during emit waits for the next one. - for (const listener of Array.from(detachListeners)) listener(); - }; - return { - wc: wc as unknown as Electron.WebContents, - attach, - detach, - sendCommand, - chromiumDetach, - destroy: () => { - destroyed = true; - listeners.get("destroyed")?.(); - }, - }; - }; - - effectIt.effect("sets the opaque base as soon as a guest attaches", () => - withManager((manager) => - Effect.gen(function* () { - const claimed = makeAttachingGuest(44); - fromId.mockReturnValue(claimed.wc); - yield* manager.prepareWebview(claimed.wc); - expect(claimed.attach).toHaveBeenCalledTimes(1); - expect(claimed.sendCommand.mock.calls[0]).toEqual([ - "Emulation.setDefaultBackgroundColorOverride", - { color: { r: 255, g: 255, b: 255, a: 1 } }, - ]); - - yield* manager.createTab("tab_early"); - yield* manager.registerWebview("tab_early", 44); - yield* manager.setColorScheme("tab_early", "dark"); - expect(claimed.attach).toHaveBeenCalledTimes(1); - expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-color-scheme", value: "dark" }], - }); - - const unclaimed = makeAttachingGuest(45); - yield* manager.prepareWebview(unclaimed.wc); - expect(unclaimed.attach).toHaveBeenCalledTimes(1); - unclaimed.destroy(); - yield* Effect.yieldNow; - expect(unclaimed.detach).toHaveBeenCalledTimes(1); - expect(claimed.detach).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("reattaches a debugger that Chromium dropped on its own", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAttachingGuest(48); - fromId.mockReturnValue(guest.wc); - yield* manager.createTab("tab_detached"); - yield* manager.registerWebview("tab_detached", 48); - yield* Effect.yieldNow; - expect(guest.attach).toHaveBeenCalledTimes(1); + webContents: { setBackgroundThrottling: firstWindowThrottling }, + } as never); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: replacementWindowThrottling }, + } as never); - guest.chromiumDetach(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - expect(guest.attach).toHaveBeenCalledTimes(2); + closeFirstWindow?.(); + yield* manager.startRecording("tab_replaced_window_close"); + expect(firstWindowThrottling).not.toHaveBeenCalled(); + expect(replacementWindowThrottling.mock.calls).toEqual([[false]]); + yield* manager.stopRecording("tab_replaced_window_close"); + expect(replacementWindowThrottling.mock.calls).toEqual([[false], [true]]); }), ), ); - effectIt.effect("skips a guest destroyed while another guest's session opens", () => - withManager((manager) => - Effect.gen(function* () { - const slow = makeAttachingGuest(46); - let releaseSlow = () => {}; - const slowCommand = new Promise((resolve) => { - releaseSlow = resolve; - }); - slow.sendCommand.mockImplementation(() => slowCommand); - const queued = makeAttachingGuest(47); - - const slowFiber = yield* manager - .prepareWebview(slow.wc) - .pipe(Effect.forkChild({ startImmediately: true })); - const queuedFiber = yield* manager - .prepareWebview(queued.wc) - .pipe(Effect.forkChild({ startImmediately: true })); - queued.destroy(); - releaseSlow(); - - expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true); - expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true); - expect(slow.attach).toHaveBeenCalledTimes(1); - expect(queued.attach).not.toHaveBeenCalled(); - }), - ), - ); - - const makeAudioWebContents = (id: number) => { - const listeners = new Map void>(); - const setAudioMuted = vi.fn(); - let audible = false; - let audibleAfterFirstRead = false; - let audibleReads = 0; - return { - setAudioMuted, - emitAudioState: (next: boolean) => { - audible = next; - listeners.get("audio-state-changed")?.({ audible: next } as never); - }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ - startPlayingAfterFirstRead: () => { - audibleAfterFirstRead = true; - }, - wc: { - id, - isDestroyed: () => false, - isDevToolsOpened: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted, - isCurrentlyAudible: () => { - audibleReads += 1; - if (audibleAfterFirstRead && audibleReads > 1) return true; - return audible; - }, - loadURL: vi.fn(async () => undefined), - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn((event: string) => { - listeners.delete(event); - }), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never, - }; - }; - - effectIt.effect("mutes the guest and re-applies the mute across webview swaps", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio"); - yield* manager.registerWebview("tab_audio", 42); - yield* Effect.yieldNow; - - expect(states.at(-1)?.audioMuted).toBe(false); - - yield* manager.setAudioMuted("tab_audio", true); - - expect(first.setAudioMuted).toHaveBeenCalledWith(true); - expect(states.at(-1)?.audioMuted).toBe(true); - - const replacement = makeAudioWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_audio", 43); - yield* Effect.yieldNow; - - expect(replacement.setAudioMuted).toHaveBeenCalledWith(true); - expect(states.at(-1)?.audioMuted).toBe(true); - - yield* manager.setAudioMuted("tab_audio", false); - - expect(replacement.setAudioMuted).toHaveBeenLastCalledWith(false); - expect(states.at(-1)?.audioMuted).toBe(false); - }), - ), - ); - - effectIt.effect("fails and rolls back when the guest refuses a mute", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_fail"); - yield* manager.registerWebview("tab_audio_fail", 42); - yield* Effect.yieldNow; - - guest.setAudioMuted.mockImplementationOnce(() => { - throw new Error("guest refused"); - }); - const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(states.at(-1)?.audioMuted).toBe(false); - }), - ), - ); - - effectIt.effect("still registers a guest that refuses the mute reassert", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - yield* manager.createTab("tab_audio_attach_fail"); - yield* manager.registerWebview("tab_audio_attach_fail", 42); - yield* Effect.yieldNow; - yield* manager.setAudioMuted("tab_audio_attach_fail", true); - - const replacement = makeAudioWebContents(43); - replacement.setAudioMuted.mockImplementationOnce(() => undefined); - replacement.setAudioMuted.mockImplementationOnce(() => { - throw new Error("guest went away"); - }); - fromId.mockReturnValue(replacement.wc); - - const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - }), - ), - ); - - effectIt.effect("reconciles audibility that changed while the guest attached", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - guest.startPlayingAfterFirstRead(); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_window"); - yield* manager.registerWebview("tab_audio_window", 42); - yield* Effect.yieldNow; - - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. - expect(states.at(-1)?.audible).toBe(true); - }), - ), - ); - - effectIt.effect("publishes audibility transitions and drops repeats", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audible"); - yield* manager.registerWebview("tab_audible", 42); - yield* Effect.yieldNow; - - expect(states.at(-1)?.audible).toBe(false); - - guest.emitAudioState(true); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(true); - - // Chromium re-emits per media element; only real transitions publish. - const publishedAfterFirst = states.length; - guest.emitAudioState(true); - yield* Effect.yieldNow; - expect(states.length).toBe(publishedAfterFirst); - - guest.emitAudioState(false); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(false); - expect(states.length).toBeGreaterThan(publishedAfterFirst); - }), - ), - ); - - effectIt.effect("ignores audio state from a replaced guest", () => - withManager((manager) => - Effect.gen(function* () { - const first = makeAudioWebContents(42); - fromId.mockReturnValue(first.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_stale"); - yield* manager.registerWebview("tab_audio_stale", 42); - yield* Effect.yieldNow; - - const replacement = makeAudioWebContents(43); - fromId.mockReturnValue(replacement.wc); - yield* manager.registerWebview("tab_audio_stale", 43); - yield* Effect.yieldNow; - - const publishedBefore = states.length; - first.emitAudioState(true); - yield* Effect.yieldNow; - - expect(states.length).toBe(publishedBefore); - expect(states.at(-1)?.audible).toBe(false); - }), - ), - ); - - effectIt.effect("carries mute and audibility across navigation", () => - withManager((manager) => - Effect.gen(function* () { - const guest = makeAudioWebContents(42); - fromId.mockReturnValue(guest.wc); - const states: PreviewManager.PreviewTabState[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.createTab("tab_audio_nav"); - yield* manager.registerWebview("tab_audio_nav", 42); - yield* Effect.yieldNow; - - yield* manager.setAudioMuted("tab_audio_nav", true); - guest.emitAudioState(true); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(true); - - yield* manager.navigate("tab_audio_nav", "https://example.com/next"); - yield* Effect.yieldNow; - - expect(states.at(-1)?.audioMuted).toBe(true); - expect(states.at(-1)?.audible).toBe(true); - - guest.emitAudioState(false); - yield* Effect.yieldNow; - expect(states.at(-1)?.audible).toBe(false); - }), - ), - ); - - effectIt.effect("blocks late webview and capture starts during tab close", () => - withManager((manager) => - Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("close-race-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const firstWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(capturePage, 43); - const replacementListenerSpies = replacementWebContents as unknown as { - readonly on: ReturnType; - readonly off: ReturnType; - readonly ipc: { readonly off: ReturnType }; - }; - fromId.mockImplementation((id) => { - if (id === 42) return firstWebContents; - if (id === 43) return replacementWebContents; - return null; - }); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_close_register_race"); - yield* manager.registerWebview("tab_close_register_race", 42); - yield* manager.openPictureInPicture("tab_close_register_race"); - - const closeCleanupPaused = yield* Deferred.make(); - const continueCloseCleanup = yield* Deferred.make(); - yield* manager.subscribeStateChanges((_tabId, state) => - !state.pictureInPicture && state.webContentsId === 42 - ? Deferred.succeed(closeCleanupPaused, undefined).pipe( - Effect.andThen(Deferred.await(continueCloseCleanup)), - ) - : Effect.void, - ); - - const closeFiber = yield* manager - .closeTab("tab_close_register_race") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(closeCleanupPaused); - const recreateFiber = yield* manager - .createTab("tab_close_register_race") - .pipe(Effect.forkChild({ startImmediately: true })); - const registrationFiber = yield* manager - .registerWebview("tab_close_register_race", 43) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - yield* manager.closeTab("tab_close_register_race"); - const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); - yield* Deferred.succeed(continueCloseCleanup, undefined); - yield* Fiber.join(closeFiber); - const recreated = yield* Fiber.join(recreateFiber); - const registrationExit = yield* Fiber.await(registrationFiber); - - for (const exit of [registrationExit, recordingExit] as ReadonlyArray< - Exit.Exit - >) { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) continue; - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewTabNotFoundError", - tabId: "tab_close_register_race", - }); - } - expect(replacementListenerSpies.on).not.toHaveBeenCalled(); - expect(replacementListenerSpies.off).not.toHaveBeenCalled(); - expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled(); - expect(capturePage).toHaveBeenCalledOnce(); - expect(recreated.webContentsId).toBeNull(); - }), - ), - ); - - effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => - withManager((manager) => - Effect.gen(function* () { - const url = "http://localhost:5733/"; - let loading = false; - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => url, - getTitle: () => "localhost:5733", - isLoadingMainFrame: () => loading, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const statuses: PreviewManager.PreviewNavStatus[] = []; - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - statuses.push(state.navStatus); - }), - ); - yield* manager.createTab("tab_failed"); - yield* manager.registerWebview("tab_failed", 42); - - listeners.get("did-fail-load")?.( - {}, - -105, - "ERR_NAME_NOT_RESOLVED", - "https://missing-frame.example/", - false, - ); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); - - loading = false; - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - listeners.get("did-stop-loading")?.(); - listeners.get("page-title-updated")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)).toEqual({ - kind: "LoadFailed", - url, - title: "localhost:5733", - code: -102, - description: "ERR_CONNECTION_REFUSED", - }); - - loading = true; - listeners.get("did-start-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Loading"); - - loading = false; - listeners.get("did-stop-loading")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - - listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("LoadFailed"); - - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - expect(statuses.at(-1)?.kind).toBe("Success"); - }), - ), - ); - - effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () => - withManager((manager) => - Effect.gen(function* () { - const preview = makeFaviconWebContents(); - Object.assign(preview.webContents, { isLoading: () => true }); - fromId.mockReturnValue(preview.webContents); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => void states.push(state)), - ); - yield* manager.createTab("tab_iframe"); - yield* manager.registerWebview("tab_iframe", 42); - - preview.listeners.get("did-start-loading")?.(); - preview.listeners.get("did-stop-loading")?.(); - yield* settle(() => states.length > 2); - expect(states.at(-1)?.navStatus.kind).toBe("Success"); - }), - ), - ); - - effectIt.effect("captures a PNG screenshot into browser artifacts", () => - withManager((manager) => - Effect.gen(function* () { - const png = Buffer.from("preview-png"); - const capturePage = vi.fn(async () => ({ toPNG: () => png })); - const listeners = new Map void>(); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com:8443/path?query=value", - setBackgroundThrottling: vi.fn(), - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn((event: string, listener: (...args: never[]) => void) => { - listeners.set(event, listener); - }), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - - expect(webviewSend).toHaveBeenCalledWith( - "preview:annotation-theme", - expect.objectContaining({ - colorScheme: "light", - primary: "oklch(0.488 0.217 264)", - }), - ); - - const artifact = yield* manager.captureScreenshot("tab_1"); - - expect(capturePage).toHaveBeenCalledTimes(2); - expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts"); - expect(writeFile).toHaveBeenCalledWith(artifact.path, png); - expect(artifact).toMatchObject({ - tabId: "tab_1", - mimeType: "image/png", - sizeBytes: png.byteLength, - }); - expect(artifact.path).toMatch( - /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, - ); - - // Chromium reports UnknownVizError while a hidden guest warms its - // first compositor frame, so transient failures are retried. - capturePage.mockClear(); - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(1_000); - const retriedExit = yield* Fiber.join(retriedFiber); - expect(Exit.isSuccess(retriedExit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(4); - - capturePage.mockClear(); - const captureCause = new Error("capture failed"); - capturePage.mockRejectedValue(captureCause); - const writesBeforeFailure = writeFile.mock.calls.length; - const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(1_000); - const exit = yield* Fiber.join(failingFiber); - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(6); - expect(writeFile).toHaveBeenCalledTimes(writesBeforeFailure); - expect(fromId(42)?.setBackgroundThrottling).toHaveBeenLastCalledWith(true); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewOperationError", - operation: "captureScreenshot.capturePage", - tabId: "tab_1", - webContentsId: 42, - cause: captureCause, - }); - }), - ), - ); - - effectIt.effect("captures final pixels after warmup fails and paint readiness settles", () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - capturePage - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockRejectedValueOnce(new Error("UnknownVizError")) - .mockResolvedValue(makeStillImage("final-png")); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(300); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(writeFile).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - paint.resolve(); - const artifact = yield* Fiber.join(fiber); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(writeFile.mock.calls).toEqual([[artifact.path, Buffer.from("final-png")]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => - withManager((manager) => - Effect.gen(function* () { - const firstPng = Buffer.from("first-preview-png"); - const secondPng = Buffer.from("second-preview-png"); - const pngImage = (png: Buffer) => ({ - toPNG: () => png, - toJPEG: () => png, - getSize: () => ({ width: 1280, height: 720 }), - }); - const capturePage = vi - .fn<() => Promise>>() - .mockResolvedValueOnce(pngImage(firstPng)) - .mockResolvedValueOnce(pngImage(firstPng)) - .mockResolvedValueOnce(pngImage(secondPng)) - .mockResolvedValueOnce(pngImage(secondPng)); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. - const first = yield* manager.captureScreenshot("tab_1"); - const second = yield* manager.captureScreenshot("tab_1"); - - expect(first.createdAt).toBe(second.createdAt); - expect(first.id).not.toBe(second.id); - expect(first.path).not.toBe(second.path); - expect(first.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/); - expect(second.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/); - expect(writeFile.mock.calls).toEqual([ - [first.path, firstPng], - [second.path, secondPng], - ]); - }), - ), - ); - - effectIt.effect( - "waits for paint before capturing and restores rendering after overlapping requests", - () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { image, capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - const first = Promise.withResolvers(); - const second = Promise.withResolvers(); - capturePage - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise); - const hostThrottling = vi.fn(); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: hostThrottling }, - } as never); - const one = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - const two = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile).not.toHaveBeenCalled(); - expect(hostThrottling.mock.calls).toEqual([[false]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - paint.resolve(); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(4); - first.resolve(image); - yield* Fiber.join(one); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - second.resolve(image); - yield* Fiber.join(two); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([ - Buffer.from("fresh-png"), - Buffer.from("fresh-png"), - ]); - expect(hostThrottling.mock.calls).toEqual([[false], [true]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("keeps picture-in-picture rendering after a still image completes", () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const pending = Promise.withResolvers(); - capturePage - .mockResolvedValueOnce(image) - .mockResolvedValueOnce(makeStillImage("discarded-warmup")) - .mockReturnValueOnce(pending.promise); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - yield* manager.openPictureInPicture("tab_still"); - const still = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(writeFile).not.toHaveBeenCalled(); - pending.resolve(image); - yield* Fiber.join(still); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.closePictureInPicture("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("keeps a pending still image rendering when recording ends", () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const pending = Promise.withResolvers(); - capturePage.mockResolvedValueOnce(image).mockReturnValue(pending.promise); - yield* manager.startRecording("tab_still"); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - yield* manager.stopRecording("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - pending.resolve(image); - yield* Fiber.join(fiber); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect( - "ignores late native warmup completion after interruption while recording owns rendering", - () => - withManager((manager) => - Effect.gen(function* () { - const { image, capturePage, wc, capture } = yield* setupStill(manager); - const warmup = Promise.withResolvers(); - let nativeCompleted = false; - const nativePromise = warmup.promise.then((result) => { - nativeCompleted = true; - return result; - }); - capturePage.mockReturnValueOnce(nativePromise); - const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - yield* manager.startRecording("tab_still"); - yield* Fiber.interrupt(fiber); - expect(nativeCompleted).toBe(false); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - warmup.resolve(image); - yield* TestClock.adjust(0); - expect(nativeCompleted).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.stopRecording("tab_still"); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect( - "ignores an interrupted warmup that resolves while the replacement guest is capturing", - () => - withManager((manager) => - Effect.gen(function* () { - const fresh = makeStillImage("replacement-png"); - const late = Promise.withResolvers(); - const finalCapture = Promise.withResolvers(); - const oldCapture = vi.fn(() => late.promise); - const replacementCapture = vi - .fn() - .mockResolvedValueOnce(fresh) - .mockReturnValue(finalCapture.promise); - const hostThrottling = vi.fn(); - const host = Object.assign(makeTestHostWebContents(), { - setBackgroundThrottling: hostThrottling, - }); - const original = makeTestStillImageWebContents(oldCapture, 42, host); - const replacement = makeTestStillImageWebContents(replacementCapture, 43, host); - holdStillImagePaint(original, new Promise(() => {})); - fromId.mockImplementation((id) => (id === 42 ? original : replacement)); - yield* manager.createTab("tab_still"); - yield* manager.registerWebview("tab_still", 42); - yield* manager.setColorScheme("tab_still", "system"); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: host, - } as never); - const failed = yield* Effect.exit(manager.captureScreenshot("tab_still")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(0); - yield* Fiber.interrupt(failed); - yield* manager.registerWebview("tab_still", 43); - yield* manager.setColorScheme("tab_still", "system"); - const healthy = yield* manager - .captureScreenshot("tab_still") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - late.resolve({ ...fresh, toPNG: () => Buffer.from("late-old-warmup") }); - yield* TestClock.adjust(0); - expect(writeFile).not.toHaveBeenCalled(); - expect(oldCapture).toHaveBeenCalledOnce(); - expect(replacementCapture).toHaveBeenCalledTimes(2); - expect(hostThrottling.mock.calls).toEqual([[false], [true], [false]]); - expect(replacement.setBackgroundThrottling.mock.calls).toEqual([[false]]); - finalCapture.resolve(fresh); - yield* Fiber.join(healthy); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([ - Buffer.from("replacement-png"), - ]); - expect(hostThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); - expect(replacement.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("paints a loading document with isolated scheduling despite page overrides", () => - withManager((manager) => - Effect.gen(function* () { - for (const mode of ["suppressed", "synchronous", "timeout-before", "timeout-after"]) { - writeFile.mockClear(); - let pixels = "old-pixels"; - const frames: Array<() => void> = []; - let expire = () => {}; - let paintConfirmed: unknown; - const clearTimeout = vi.fn(); - const probeStarted = Promise.withResolvers(); - const pageFrame = vi.fn((callback: () => void) => { - if (mode === "synchronous") callback(); - }); - const page = NodeVM.createContext({ - Promise: undefined, - requestAnimationFrame: pageFrame, - }); - const isolated = NodeVM.createContext({ - requestAnimationFrame: (callback: () => void) => frames.push(callback), - cancelAnimationFrame: (id: number) => { - frames.splice(id - 1, 1); - }, - setTimeout: (callback: () => void, delay: number) => { - expect(delay).toBe(1_000); - expire = callback; - return 1; - }, - clearTimeout, - }); - const capturePage = vi.fn(async () => makeStillImage(pixels)); - const wc = makeTestStillImageWebContents(capturePage); - Object.assign(wc, { isLoading: () => true }); - vi.mocked(wc.executeJavaScript).mockImplementation(async (expression) => - NodeVM.runInContext(expression, page), - ); - vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method, params) => { - if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; - if (method === "Runtime.evaluate") { - const pending = NodeVM.runInContext(params?.["expression"] as string, isolated); - probeStarted.resolve(); - paintConfirmed = await pending; - return { result: { value: paintConfirmed } }; - } - return undefined; - }); - fromId.mockReturnValue(wc); - const tabId = `tab_loading_${mode}`; - yield* manager.createTab(tabId); - yield* manager.registerWebview(tabId, wc.id); - yield* manager.setColorScheme(tabId, "system"); - const still = yield* manager - .captureScreenshot(tabId) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => probeStarted.promise); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledOnce(); - expect(writeFile).not.toHaveBeenCalled(); - if (mode !== "timeout-before") frames.shift()?.(); - pixels = "new-pixels"; - yield* TestClock.adjust(0); - expect(writeFile).not.toHaveBeenCalled(); - if (mode.startsWith("timeout")) expire(); - else frames.shift()?.(); - yield* Fiber.join(still); - expect(paintConfirmed).toBe(!mode.startsWith("timeout")); - expect(frames).toHaveLength(0); - expect(clearTimeout.mock.calls).toEqual(mode.startsWith("timeout") ? [] : [[1]]); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("new-pixels")]); - expect(pageFrame).not.toHaveBeenCalled(); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - yield* manager.closeTab(tabId); - } - }), - ), - ); - - effectIt.effect("captures available pixels without taking another debugger's ownership", () => - withManager((manager) => - Effect.gen(function* () { - for (const devToolsOpen of [true, false]) { - const capturePage = vi.fn(async () => makeStillImage("available-pixels")); - const wc = makeTestStillImageWebContents(capturePage); - Object.assign(wc, { isDevToolsOpened: () => devToolsOpen }); - vi.spyOn(wc.debugger, "isAttached").mockReturnValue(true); - fromId.mockReturnValue(wc); - const tabId = `tab_foreign_debugger_${devToolsOpen}`; - yield* manager.createTab(tabId); - yield* manager.registerWebview(tabId, wc.id); - yield* manager.captureScreenshot(tabId); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(wc.debugger.attach).not.toHaveBeenCalled(); - expect(wc.debugger.sendCommand).not.toHaveBeenCalled(); - expect(writeFile).toHaveBeenLastCalledWith( - expect.any(String), - Buffer.from("available-pixels"), - ); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - yield* manager.closeTab(tabId); - } - }), - ), - ); - - effectIt.effect( - "bounds paint readiness and ignores its late completion during another capture", - () => - withManager((manager) => - Effect.gen(function* () { - const paint = Promise.withResolvers(); - const { image, capturePage, wc, capture } = yield* setupStill(manager, paint.promise); - const first = yield* Effect.exit(capture).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(1_001); - expect(Exit.isSuccess(yield* Fiber.join(first))).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - - holdStillImagePaint(wc, Promise.resolve()); - const finalCapture = Promise.withResolvers(); - capturePage.mockResolvedValueOnce(image).mockReturnValueOnce(finalCapture.promise); - const second = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust(0); - paint.resolve(); - yield* TestClock.adjust(0); - expect(writeFile).toHaveBeenCalledOnce(); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false]]); - finalCapture.resolve(image); - yield* Fiber.join(second); - expect(writeFile).toHaveBeenCalledTimes(2); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); - }), - ), - ); - - effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => - withManager((manager) => - Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const host = makeTestHostWebContents(); - const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); - const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); - const webContentsById = new Map([ - [41, firstWebContents], - [42, secondWebContents], - ]); - fromId.mockImplementation((id) => - id === undefined ? null : (webContentsById.get(id) ?? null), - ); - - yield* manager.createTab("tab_capture_throttling_1"); - yield* manager.createTab("tab_capture_throttling_2"); - yield* manager.registerWebview("tab_capture_throttling_1", 41); - yield* manager.registerWebview("tab_capture_throttling_2", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never); - - yield* manager.startRecording("tab_capture_throttling_1"); - host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); - yield* manager.startRecording("tab_capture_throttling_2"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - - yield* manager.stopRecording("tab_capture_throttling_1"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - - yield* manager.stopRecording("tab_capture_throttling_2"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("does not commit failed starts and retries throttle restoration", () => - withManager((manager) => - Effect.gen(function* () { - const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - - yield* manager.createTab("tab_capture_throttling_failure"); - yield* manager.registerWebview("tab_capture_throttling_failure", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never); - - setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("start throttling update failed"); - }); - const failedStart = yield* Effect.exit( - manager.startRecording("tab_capture_throttling_failure"), - ); - expect(Exit.isFailure(failedStart)).toBe(true); - - yield* manager.startRecording("tab_capture_throttling_failure"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false]]); - - setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("stop throttling update failed"); - }); - yield* manager.stopRecording("tab_capture_throttling_failure"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true], [true]]); - - yield* manager.startRecording("tab_capture_throttling_failure"); - yield* manager.stopRecording("tab_capture_throttling_failure"); - expect(setBackgroundThrottling.mock.calls).toEqual([ - [false], - [false], - [true], - [true], - [false], - [true], - ]); - }), - ), - ); - - effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => - withManager((manager) => - Effect.gen(function* () { - const setWindowBackgroundThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const wc = makeTestPreviewWebContents(capturePage); - fromId.mockReturnValue(wc); - - yield* manager.createTab("tab_guest_throttling_failure"); - yield* manager.registerWebview("tab_guest_throttling_failure", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, - } as never); - - wc.setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("guest throttling update failed"); - }); - const failedStart = yield* Effect.exit( - manager.startRecording("tab_guest_throttling_failure"), - ); - expect(Exit.isFailure(failedStart)).toBe(true); - expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); - - yield* manager.startRecording("tab_guest_throttling_failure"); - yield* manager.stopRecording("tab_guest_throttling_failure"); - expect(setWindowBackgroundThrottling.mock.calls).toEqual([ - [false], - [true], - [false], - [true], - ]); - expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); - }), - ), - ); - - effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => - withManager((manager) => - Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(() => { - throw new Error("replacement throttling update failed"); - }); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - - yield* manager.createTab("tab_capture_replacement_failure"); - yield* manager.registerWebview("tab_capture_replacement_failure", 42); - yield* manager.startRecording("tab_capture_replacement_failure"); - - const failedReplacement = yield* Effect.exit( - manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling }, - } as never), - ); - expect(Exit.isFailure(failedReplacement)).toBe(true); - - yield* manager.stopRecording("tab_capture_replacement_failure"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - }), - ), - ); - - effectIt.effect("keeps frame capture closed when a replacement is destroyed during cleanup", () => - withManager((manager) => - Effect.gen(function* () { - const windowEvents = new Map void>(); - const host = makeTestHostWebContents(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const guest = makeTestPreviewWebContents(capturePage, 42, host); - fromId.mockReturnValue(guest); - yield* manager.createTab("tab_shutdown"); - yield* manager.registerWebview("tab_shutdown", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: (event: string, listener: () => void) => windowEvents.set(event, listener), - webContents: { setBackgroundThrottling: vi.fn() }, - } as never); - yield* manager.startRecording("tab_shutdown"); - const pip = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pip.pictureInPictureWindow; - }); - yield* manager.openPictureInPicture("tab_shutdown"); - const capturesBeforeClose = capturePage.mock.calls.length; - const cleanupStarted = yield* Deferred.make(); - const releaseCleanup = yield* Deferred.make(); - yield* manager.subscribeStateChanges((_tabId, state) => - state.pictureInPicture - ? Effect.void - : Deferred.succeed(cleanupStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCleanup)), - ), - ); - - windowEvents.get("closed")!(); - yield* Deferred.await(cleanupStarted); - let destroyed = false; - const replacement = yield* manager - .setMainWindow({ - isDestroyed: () => destroyed, - once: vi.fn(), - webContents: { setBackgroundThrottling: vi.fn() }, - } as never) - .pipe(Effect.forkChild({ startImmediately: true })); - try { - expect(replacement.pollUnsafe()).toBeUndefined(); - destroyed = true; - } finally { - yield* Deferred.succeed(releaseCleanup, undefined); - } - yield* Fiber.join(replacement); - - const restart = yield* Effect.exit(manager.startRecording("tab_shutdown")); - expect(Exit.isFailure(restart)).toBe(true); - if (Exit.isFailure(restart)) { - expect(Option.getOrThrow(Cause.findErrorOption(restart.cause))).toMatchObject({ - _tag: "PreviewMainWindowClosedError", - tabId: "tab_shutdown", - }); - } - expect(capturePage).toHaveBeenCalledTimes(capturesBeforeClose); - expect(pip.pictureInPictureWindow.isDestroyed()).toBe(true); - expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - const grants: Array<{ video?: unknown }> = []; - host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); - expect(grants).toEqual([{}]); - }), - ), - ); - - effectIt.effect("ignores close events from replaced main windows", () => - withManager((manager) => - Effect.gen(function* () { - let closeFirstWindow: (() => void) | undefined; - const firstWindowThrottling = vi.fn(); - const replacementWindowThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - - yield* manager.createTab("tab_replaced_window_close"); - yield* manager.registerWebview("tab_replaced_window_close", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closeFirstWindow = listener; - }), - webContents: { setBackgroundThrottling: firstWindowThrottling }, - } as never); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: replacementWindowThrottling }, - } as never); - - closeFirstWindow?.(); - yield* manager.startRecording("tab_replaced_window_close"); - expect(firstWindowThrottling).not.toHaveBeenCalled(); - expect(replacementWindowThrottling.mock.calls).toEqual([[false]]); - yield* manager.stopRecording("tab_replaced_window_close"); - expect(replacementWindowThrottling.mock.calls).toEqual([[false], [true]]); - }), - ), - ); - - effectIt.effect("releases frame capture when the main window closes", () => - withManager((manager) => - Effect.gen(function* () { - let closeMainWindow: (() => void) | undefined; - const firstWindowThrottling = vi.fn(); - const replacementWindowThrottling = vi.fn(); - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const host = makeTestHostWebContents(); - const webContentsById = new Map([ - [42, makeTestPreviewWebContents(capturePage, 42, host)], - [43, makeTestPreviewWebContents(capturePage, 43, host)], - ]); - fromId.mockImplementation((id) => - id === undefined ? null : (webContentsById.get(id) ?? null), - ); - - yield* manager.createTab("tab_window_close_recording"); - yield* manager.createTab("tab_window_close_race"); - yield* manager.registerWebview("tab_window_close_recording", 42); - yield* manager.registerWebview("tab_window_close_race", 43); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closeMainWindow = listener; - }), - webContents: { setBackgroundThrottling: firstWindowThrottling }, - } as never); - yield* manager.startRecording("tab_window_close_recording"); - expect(firstWindowThrottling.mock.calls).toEqual([[false]]); - - closeMainWindow?.(); - const racedStart = yield* Effect.exit(manager.startRecording("tab_window_close_race")); - expect(Exit.isFailure(racedStart)).toBe(true); - if (Exit.isFailure(racedStart)) { - expect(Option.getOrThrow(Cause.findErrorOption(racedStart.cause))).toMatchObject({ - _tag: "PreviewMainWindowClosedError", - tabId: "tab_window_close_race", - }); - } - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const grants: Array<{ video?: unknown }> = []; - host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); - expect(grants).toEqual([{}]); - - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: replacementWindowThrottling }, - } as never); - expect(replacementWindowThrottling).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("does not arm recording after the main window closes during warmup", () => - withManager((manager) => - Effect.gen(function* () { - let closeMainWindow: (() => void) | undefined; - let finishWarmup!: (image: TestCapturedPreviewImage) => void; - let markWarmupStarted!: () => void; - const warmupStarted = new Promise((resolve) => { - markWarmupStarted = resolve; - }); - const capturedImage = { - toJPEG: () => Buffer.from("recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - }; - const capturePage = vi.fn( - () => - new Promise((resolve) => { - markWarmupStarted(); - finishWarmup = resolve; - }), - ); - const host = makeTestHostWebContents(); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); - - yield* manager.createTab("tab_window_close_warmup"); - yield* manager.registerWebview("tab_window_close_warmup", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closeMainWindow = listener; - }), - webContents: { setBackgroundThrottling: vi.fn() }, - } as never); - - const start = yield* manager - .startRecording("tab_window_close_warmup") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => warmupStarted); - closeMainWindow?.(); - finishWarmup(capturedImage); - - const exit = yield* Fiber.await(start); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ - _tag: "PreviewMainWindowClosedError", - tabId: "tab_window_close_warmup", - }); - } - expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("tells the server when a tab it renders natively closes", () => - Effect.gen(function* () { - const host = yield* DesktopBrowserHost.DesktopBrowserHost; - const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; - const lines = yield* Queue.unbounded(); - yield* host.events.pipe( - Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))), - Effect.forkScoped, - ); - const nextType = Queue.take(lines).pipe( - Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]), - ); - const capturePage = vi.fn(async () => ({ - toPNG: () => Buffer.from("png"), - toJPEG: () => Buffer.from("jpeg"), - getSize: () => ({ width: 100, height: 80 }), - })); - fromId.mockReturnValue( - Object.assign(makeTestPreviewWebContents(capturePage, 42), { - isDevToolsOpened: () => false, - getUserAgent: () => "Electron", - }), - ); - const manager = yield* PreviewManager.PreviewManager; - yield* manager.createTab("tab_1", { serverTab }); - yield* manager.registerWebview("tab_1", 42); - expect(yield* nextType).toBe("attached"); - yield* manager.closeTab("tab_1"); - expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), - ); - - effectIt.effect("stops capture retries when the tab swaps during the retry delay", () => - withManager((manager) => - Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toPNG: () => Buffer.from("png"), - toJPEG: () => Buffer.from("jpeg"), - getSize: () => ({ width: 100, height: 80 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); - const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(60); - expect(capturePage).toHaveBeenCalledTimes(1); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); - yield* manager.registerWebview("tab_1", 43); - yield* TestClock.adjust(1_000); - const exit = yield* Fiber.join(fiber); - - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledTimes(1); - expect(writeFile).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => - withManager((manager) => - Effect.gen(function* () { - const image = { - toPNG: () => Buffer.from("stale-png"), - toJPEG: () => Buffer.from("stale-jpeg"), - getSize: () => ({ width: 100, height: 80 }), - }; - const pending = Promise.withResolvers(); - const capturePage = vi.fn(() => pending.promise); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - - const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust(0); - expect(capturePage).toHaveBeenCalledOnce(); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); - yield* manager.registerWebview("tab_1", 43); - pending.resolve(image); - const exit = yield* Fiber.join(fiber); - - expect(Exit.isFailure(exit)).toBe(true); - expect(capturePage).toHaveBeenCalledOnce(); - expect(writeFile).not.toHaveBeenCalled(); - }), - ), - ); - - effectIt.effect("grants each concurrent preview recording its own tab frame", () => - withManager((manager) => - Effect.gen(function* () { - const firstJpeg = Buffer.from("first-recording-frame"); - const secondJpeg = Buffer.from("second-recording-frame"); - const firstCapturePage = vi.fn(async () => ({ - toJPEG: () => firstJpeg, - getSize: () => ({ width: 800, height: 600 }), - })); - const secondCapturePage = vi.fn(async () => ({ - toJPEG: () => secondJpeg, - getSize: () => ({ width: 390, height: 844 }), - })); - const firstSendCommand = vi.fn(async () => undefined); - const secondSendCommand = vi.fn(async () => undefined); - const host = makeTestHostWebContents(); - const makeWebContents = ( - id: number, - capturePage: typeof firstCapturePage, - sendCommand: typeof firstSendCommand, - ) => - ({ - id, - mainFrame: { routingId: id }, - hostWebContents: host, - executeJavaScript: vi.fn(async () => - id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, - ), - isDestroyed: () => false, - getType: () => "webview", - getURL: () => `https://example.com/${id}`, - getTitle: () => `Example ${id}`, - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - setBackgroundThrottling: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - }) as never; - const webContentsById = new Map([ - [41, makeWebContents(41, firstCapturePage, firstSendCommand)], - [42, makeWebContents(42, secondCapturePage, secondSendCommand)], - ]); - fromId.mockImplementation((id) => - id === undefined ? null : (webContentsById.get(id) ?? null), - ); - yield* manager.createTab("tab_1"); - yield* manager.createTab("tab_2"); - yield* manager.registerWebview("tab_1", 41); - yield* manager.registerWebview("tab_2", 42); - - const grants: Array<{ video?: unknown }> = []; - const takeGrant = () => - host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { - grants.push(value); - }); - - yield* manager.startRecording("tab_1"); - takeGrant(); - yield* manager.startRecording("tab_2"); - takeGrant(); - expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); - - expect(firstCapturePage).toHaveBeenCalledOnce(); - expect(secondCapturePage).toHaveBeenCalledOnce(); - expect(firstSendCommand).not.toHaveBeenCalledWith( - "Page.startScreencast", - expect.anything(), - ); - expect(secondSendCommand).not.toHaveBeenCalledWith( - "Page.startScreencast", - expect.anything(), - ); - - yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { - concurrency: 2, - discard: true, - }); - }), - ), - ); - - effectIt.effect("requests display media with a fresh renderer gesture", () => - withManager((manager) => - Effect.gen(function* () { - const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - - expect(host.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), - true, - ); - expect(host.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining("tab_race_a"), - true, - ); - takeGrant(); - yield* manager.stopRecording("tab_race_a"); - }), - ), - ); - - effectIt.live("settles both starts when two tabs race for the capture stream", () => - withManager((manager) => - Effect.gen(function* () { - const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - const exits = yield* Effect.all( - [ - Effect.exit(manager.startRecording("tab_race_a")), - Effect.exit(manager.startRecording("tab_race_b")), - ], - { concurrency: 2 }, - ); - - const [exitA, exitB] = exits; - expect(exits.filter(Exit.isSuccess)).toHaveLength(1); - const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; - if (Exit.isSuccess(loserExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ - _tag: "PreviewRecordingArmConflictError", - }); - - takeGrant(); - expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); - expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); - }), - ), - ); - - effectIt.effect("releases an armed slot that the renderer never redeemed", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); - if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); - expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ - _tag: "PreviewRecordingArmConflictError", - tabId: "tab_race_b", - armedTabId: "tab_race_a", - }); - - yield* TestClock.adjust(10_000); - yield* manager.startRecording("tab_race_b"); - takeGrant(); - expect(grants).toEqual([{ video: { routingId: 42 } }]); - - yield* manager.stopRecording("tab_race_a"); - yield* manager.stopRecording("tab_race_b"); - }), - ), - ); - - effectIt.effect("denies a display-media request that arrives after the arm went stale", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - yield* TestClock.adjust(10_000); - takeGrant(); - expect(grants).toEqual([{}]); - - yield* manager.stopRecording("tab_race_a"); - }), - ), - ); - - effectIt.effect("only lets the host frame that armed a recording claim its stream", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - takeGrant({ frameTreeNodeId: 999 }); - takeGrant(); - - expect(grants).toEqual([{}, { video: { routingId: 41 } }]); - yield* manager.stopRecording("tab_race_a"); - }), - ), - ); - - effectIt.effect("reclaims the arm slot from a destroyed webContents", () => - withManager((manager) => - Effect.gen(function* () { - const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); - - yield* manager.startRecording("tab_race_a"); - destroy(41); - yield* manager.startRecording("tab_race_b"); - takeGrant(); - expect(grants).toEqual([{ video: { routingId: 42 } }]); - - yield* manager.stopRecording("tab_race_b"); - }), - ), - ); - - effectIt.effect( - "restores the native cursor when recording startup fails, then allows a retry", - () => - withManager((manager) => - Effect.gen(function* () { - const host = makeTestHostWebContents(); - host.executeJavaScript.mockResolvedValueOnce(false); - let cursorActive = false; - const cursorAtCapture: boolean[] = []; - const contents = Object.assign( - makeTestPreviewWebContents( - async () => { - cursorAtCapture.push(cursorActive); - return { - toJPEG: () => Buffer.from("frame"), - getSize: () => ({ width: 800, height: 600 }), - }; - }, - 42, - host, - ), - { - send: (channel: string, active: unknown) => { - if (channel === "preview:recording-cursor") cursorActive = active === true; - }, - }, - ); - fromId.mockReturnValue(contents as never); - yield* manager.createTab("tab_cursor"); - yield* manager.registerWebview("tab_cursor", 42); - const failed = yield* Effect.exit(manager.startRecording("tab_cursor")); - expect(Exit.isFailure(failed)).toBe(true); - expect(cursorAtCapture).toEqual([true]); - expect(cursorActive).toBe(false); - - yield* manager.startRecording("tab_cursor"); - expect(cursorAtCapture).toEqual([true, true]); - expect(cursorActive).toBe(true); - yield* manager.stopRecording("tab_cursor"); - expect(cursorActive).toBe(false); - }), - ), - ); - - effectIt.effect("restores the recording cursor after navigation only while recording", () => - withManager((manager) => - Effect.gen(function* () { - const listeners = new Map void>(); - let cursorActive = false; - let cursorUpdated: (() => void) | undefined; - let inputOptions: unknown; - const options = { showKeyPresses: true, showMousePresses: false }; - const contents = Object.assign( - makeTestPreviewWebContents(async () => ({ - toJPEG: () => Buffer.from("frame"), - getSize: () => ({ width: 800, height: 600 }), - })), - { - on: (event: string, listener: () => void) => listeners.set(event, listener), - send: (channel: string, active: unknown, recordingOptions: unknown) => { - if (channel !== "preview:recording-cursor") return; - cursorActive = active === true; - inputOptions = recordingOptions; - cursorUpdated?.(); - }, - }, - ); - fromId.mockReturnValue(contents as never); - yield* manager.createTab("tab_cursor_reload"); - yield* manager.registerWebview("tab_cursor_reload", 42); - yield* manager.startRecording("tab_cursor_reload", options); - for (const recording of [true, false]) { - if (!recording) yield* manager.stopRecording("tab_cursor_reload"); - cursorActive = false; - const restored = new Promise((resolve) => { - cursorUpdated = resolve; - }); - listeners.get("dom-ready")?.(); - yield* Effect.promise(() => restored); - cursorUpdated = undefined; - expect(cursorActive).toBe(recording); - expect(inputOptions).toEqual(recording ? options : undefined); - } - }), - ), - ); - - effectIt.effect("gates recording decorations and isolates failed subscribers", () => - withManager((manager) => - Effect.gen(function* () { - const callbacks = new Map< - string, - (event: unknown, input: unknown) => Fiber.Fiber | undefined - >(); - const contents = Object.assign( - makeTestPreviewWebContents(async () => ({ - toJPEG: () => Buffer.from("frame"), - getSize: () => ({ width: 800, height: 600 }), - })), - { - ipc: { - on: ( - channel: string, - callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, - ) => callbacks.set(channel, callback), - off: vi.fn(), - }, - }, - ); - fromId.mockReturnValue(contents as never); - yield* manager.createTab("tab_recording_input"); - yield* manager.registerWebview("tab_recording_input", 42); - const received: DesktopPreviewRecordingInputEvent[] = []; - yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); - yield* manager.subscribeRecordingInputs((event) => - Effect.sync(() => { - received.push(event); - }), - ); - const send = (input: unknown) => - Effect.gen(function* () { - const fiber = callbacks.get("preview:recording-input")?.(null, input); - if (fiber) yield* Fiber.join(fiber); - }); - const key = { type: "key", label: "⌘C", held: true, width: 800 }; - const pointer = { - type: "pointer", - phase: "down", - x: 120, - y: 80, - width: 800, - height: 600, - }; - yield* send(key); - expect(received).toEqual([]); - yield* manager.startRecording("tab_recording_input", { - showKeyPresses: true, - showMousePresses: false, - }); - yield* send(key); - yield* send(pointer); - yield* send({ ...key, width: 0 }); - expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); - yield* manager.stopRecording("tab_recording_input"); - yield* send(key); - expect(received).toHaveLength(1); - yield* manager.startRecording("tab_recording_input", { - showKeyPresses: false, - showMousePresses: true, - }); - yield* send(key); - yield* send(pointer); - expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); - expect(received).toHaveLength(2); - }), - ), - ); - - effectIt.effect( - "releases queued STOP after stalled recording warmups and ignores late completion", - () => - withManager((manager) => - Effect.gen(function* () { - const warmupStarted = Promise.withResolvers(); - const firstWarmup = Promise.withResolvers(); - const lateWarmup = Promise.withResolvers(); - const capturePage = vi - .fn<() => Promise>() - .mockImplementationOnce(() => { - warmupStarted.resolve(); - return firstWarmup.promise; - }) - .mockImplementation(() => lateWarmup.promise); - const host = makeTestHostWebContents(); - const webContents = makeTestPreviewWebContents(capturePage, 42, host); - const windowThrottling = vi.fn(); - fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_recording_stalled_warmup"); - yield* manager.registerWebview("tab_recording_stalled_warmup", 42); - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: { setBackgroundThrottling: windowThrottling }, - } as never); - - const image = { - toJPEG: () => Buffer.from("late-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - }; - try { - const start = yield* manager - .startRecording("tab_recording_stalled_warmup") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => warmupStarted.promise); - let stopped = false; - const stop = yield* manager.stopRecording("tab_recording_stalled_warmup").pipe( - Effect.andThen( - Effect.sync(() => { - stopped = true; - }), - ), - Effect.forkChild({ startImmediately: true }), - ); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(windowThrottling.mock.calls).toEqual([[false]]); - yield* TestClock.adjust(1_000); - expect(stopped).toBe(false); - expect(host.executeJavaScript).not.toHaveBeenCalled(); - yield* TestClock.adjust(1_000); - expect(stopped).toBe(true); - yield* Fiber.join(start); - yield* Fiber.join(stop); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(host.executeJavaScript).toHaveBeenCalledOnce(); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(windowThrottling.mock.calls).toEqual([[false], [true]]); - expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); - - lateWarmup.resolve(image); - yield* TestClock.adjust(0); - const streams: Array<{ video?: unknown }> = []; - host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => streams.push(value)); - expect(streams).toEqual([{}]); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(host.executeJavaScript).toHaveBeenCalledOnce(); - expect(webContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(windowThrottling.mock.calls).toEqual([[false], [true]]); - expect(webviewSend).toHaveBeenLastCalledWith("preview:recording-cursor", false); - } finally { - // Release stalled native calls after a failing assertion against unchanged base. - firstWarmup.resolve(image); - lateWarmup.resolve(image); - } - }), - ), - ); - - effectIt.effect("continues native recording when the source warmup fails", () => - withManager((manager) => - Effect.gen(function* () { - const capturePage = vi.fn(async () => { - throw new Error("source is not ready"); - }); + effectIt.effect("releases frame capture when the main window closes", () => + withManager((manager) => + Effect.gen(function* () { + let closeMainWindow: (() => void) | undefined; + const firstWindowThrottling = vi.fn(); + const replacementWindowThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); const host = makeTestHostWebContents(); - const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), - }); - fromId.mockReturnValue(webContents); + const webContentsById = new Map([ + [42, makeTestPreviewWebContents(capturePage, 42, host)], + [43, makeTestPreviewWebContents(capturePage, 43, host)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); - yield* manager.createTab("tab_recording_warmup_failure"); - yield* manager.registerWebview("tab_recording_warmup_failure", 42); + yield* manager.createTab("tab_window_close_recording"); + yield* manager.createTab("tab_window_close_race"); + yield* manager.registerWebview("tab_window_close_recording", 42); + yield* manager.registerWebview("tab_window_close_race", 43); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeMainWindow = listener; + }), + webContents: { setBackgroundThrottling: firstWindowThrottling }, + } as never); + yield* manager.startRecording("tab_window_close_recording"); + expect(firstWindowThrottling.mock.calls).toEqual([[false]]); - yield* manager.startRecording("tab_recording_warmup_failure"); - expect(capturePage).toHaveBeenCalledTimes(2); + closeMainWindow?.(); + const racedStart = yield* Effect.exit(manager.startRecording("tab_window_close_race")); + expect(Exit.isFailure(racedStart)).toBe(true); + if (Exit.isFailure(racedStart)) { + expect(Option.getOrThrow(Cause.findErrorOption(racedStart.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_window_close_race", + }); + } + yield* Effect.yieldNow; + yield* Effect.yieldNow; - const handler = host.displayMediaHandler(); - const streams: Array<{ video?: unknown }> = []; - handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); - handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); - expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); - yield* manager.stopRecording("tab_recording_warmup_failure"); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: replacementWindowThrottling }, + } as never); + expect(replacementWindowThrottling).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("serializes recording source acquisition with webview replacement", () => + effectIt.effect("does not arm recording after the main window closes during warmup", () => withManager((manager) => Effect.gen(function* () { - const capturedImage = { - toJPEG: () => Buffer.from("unused-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - }; + let closeMainWindow: (() => void) | undefined; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; let markWarmupStarted!: () => void; const warmupStarted = new Promise((resolve) => { markWarmupStarted = resolve; }); - let finishWarmup!: (image: TestCapturedPreviewImage) => void; - const capturePage = vi - .fn<() => Promise>() - .mockImplementationOnce( - () => - new Promise((resolve) => { - markWarmupStarted(); - finishWarmup = resolve; - }), - ) - .mockResolvedValue(capturedImage); - const initialWebContents = makeTestPreviewWebContents(capturePage, 42); - const replacementOn = vi.fn(); - const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { - on: replacementOn, - }); - fromId.mockImplementation((id) => { - if (id === 42) return initialWebContents; - if (id === 43) return replacementWebContents; - return null; - }); + const capturedImage = { + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ); + const host = makeTestHostWebContents(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); + + yield* manager.createTab("tab_window_close_warmup"); + yield* manager.registerWebview("tab_window_close_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeMainWindow = listener; + }), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never); - yield* manager.createTab("tab_recording_replacement_race"); - yield* manager.registerWebview("tab_recording_replacement_race", 42); const start = yield* manager - .startRecording("tab_recording_replacement_race") + .startRecording("tab_window_close_warmup") .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.promise(() => warmupStarted); - const replacement = yield* manager - .registerWebview("tab_recording_replacement_race", 43) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - expect(replacementOn).not.toHaveBeenCalled(); - + closeMainWindow?.(); finishWarmup(capturedImage); - yield* Fiber.join(start); - yield* Fiber.join(replacement); - expect(replacementOn).toHaveBeenCalled(); - expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); - yield* manager.stopRecording("tab_recording_replacement_race"); - expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); - expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ - [false], - [true], - ]); + + const exit = yield* Fiber.await(start); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_window_close_warmup", + }); + } + expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("shares background frame capture between recording and picture-in-picture", () => + effectIt.effect("tells the server when a tab it renders natively closes", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const lines = yield* Queue.unbounded(); + yield* host.events.pipe( + Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))), + Effect.forkScoped, + ); + const nextType = Queue.take(lines).pipe( + Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]), + ); + const capturePage = vi.fn(async () => ({ + toPNG: () => Buffer.from("png"), + toJPEG: () => Buffer.from("jpeg"), + getSize: () => ({ width: 100, height: 80 }), + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage, 42), { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + }), + ); + const manager = yield* PreviewManager.PreviewManager; + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + expect(yield* nextType).toBe("attached"); + yield* manager.closeTab("tab_1"); + expect(yield* nextType).toBe("detached"); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { - const setBackgroundThrottling = vi.fn(); - const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { - setBackgroundThrottling, - }); - const jpeg = Buffer.from("shared-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue({ - id: 42, - mainFrame: { routingId: 42 }, - hostWebContents: mainWindowWebContents, - executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - setBackgroundThrottling: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - capturePage, - } as never); - - const pictureInPictureListeners = new Map void>(); - const pictureInPictureSend = vi.fn(); - const pictureInPictureWindow = { - isDestroyed: vi.fn(() => false), - once: vi.fn((event: string, listener: () => void) => { - pictureInPictureListeners.set(event, listener); - }), - setAlwaysOnTop: vi.fn(), - setVisibleOnAllWorkspaces: vi.fn(), - setAspectRatio: vi.fn(), - getContentSize: vi.fn(() => [480, 320] as [number, number]), - setContentSize: vi.fn(), - loadURL: vi.fn(async () => undefined), - showInactive: vi.fn(), - close: vi.fn(() => { - pictureInPictureListeners.get("closed")?.(); - }), - webContents: { - on: vi.fn(), - off: vi.fn(), - send: pictureInPictureSend, - }, + const image = { + toPNG: () => Buffer.from("stale-png"), + toJPEG: () => Buffer.from("stale-jpeg"), + getSize: () => ({ width: 100, height: 80 }), }; - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const states: PreviewManager.PreviewTabState[] = []; - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.setMainWindow({ - isDestroyed: () => false, - once: vi.fn(), - webContents: mainWindowWebContents, - } as never); - - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); - yield* manager.createTab("tab_pip"); - yield* manager.registerWebview("tab_pip", 42); - yield* manager.openPictureInPicture("tab_pip"); - - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - expect(browserWindowConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - alwaysOnTop: true, - show: false, - skipTaskbar: true, - webPreferences: expect.objectContaining({ - preload: "/tmp/t3/desktop/preview-pip-preload.cjs", - backgroundThrottling: false, - }), - }), - ); - expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce(); - expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { - visibleOnFullScreen: true, - skipTransformProcessType: true, - }); - expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); - expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(523, 294, false); - expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan( - pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0, - ); - expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan( - pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0, - ); - expect(pictureInPictureSend).toHaveBeenCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ - tabId: "tab_pip", - data: jpeg.toString("base64"), - width: 1280, - height: 720, - }), + const pending = Promise.withResolvers(); + const capturePage = vi.fn(() => pending.promise); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42)); + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + + const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( + Effect.forkChild({ startImmediately: true }), ); - expect(states.at(-1)?.pictureInPicture).toBe(true); + yield* TestClock.adjust(0); expect(capturePage).toHaveBeenCalledOnce(); - const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; - - yield* manager.startRecording("tab_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(recordingFrames).toHaveLength(0); - - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); - expect(recordingFrames).toHaveLength(0); - - yield* manager.stopRecording("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); - const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; - yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(4); - expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); - expect(recordingFrames).toHaveLength(0); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); + yield* manager.registerWebview("tab_1", 43); + pending.resolve(image); + const exit = yield* Fiber.join(fiber); - setBackgroundThrottling.mockImplementationOnce(() => { - throw new Error("picture-in-picture throttling restore failed"); - }); - yield* manager.closePictureInPicture("tab_pip"); - expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [true]]); - expect(pictureInPictureWindow.close).toHaveBeenCalledOnce(); - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + expect(Exit.isFailure(exit)).toBe(true); + expect(capturePage).toHaveBeenCalledOnce(); + expect(writeFile).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () => + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("shared-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), + const firstJpeg = Buffer.from("first-recording-frame"); + const secondJpeg = Buffer.from("second-recording-frame"); + const firstCapturePage = vi.fn(async () => ({ + toJPEG: () => firstJpeg, + getSize: () => ({ width: 800, height: 600 }), })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), + const secondCapturePage = vi.fn(async () => ({ + toJPEG: () => secondJpeg, + getSize: () => ({ width: 390, height: 844 }), + })); + const firstSendCommand = vi.fn(async () => undefined); + const secondSendCommand = vi.fn(async () => undefined); + const host = makeTestHostWebContents(); + const makeWebContents = ( + id: number, + capturePage: typeof firstCapturePage, + sendCommand: typeof firstSendCommand, + ) => + ({ + id, + mainFrame: { routingId: id }, + hostWebContents: host, + executeJavaScript: vi.fn(async () => + id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, + ), + isDestroyed: () => false, + getType: () => "webview", + getURL: () => `https://example.com/${id}`, + getTitle: () => `Example ${id}`, + isLoadingMainFrame: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), + isCurrentlyAudible: () => false, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setIgnoreMenuShortcuts: vi.fn(), + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + capturePage, + }) as never; + const webContentsById = new Map([ + [41, makeWebContents(41, firstCapturePage, firstSendCommand)], + [42, makeWebContents(42, secondCapturePage, secondSendCommand)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), ); - yield* manager.createTab("tab_recording_then_pip"); - yield* manager.registerWebview("tab_recording_then_pip", 42); - yield* manager.startRecording("tab_recording_then_pip"); + yield* manager.createTab("tab_1"); + yield* manager.createTab("tab_2"); + yield* manager.registerWebview("tab_1", 41); + yield* manager.registerWebview("tab_2", 42); - expect(recordingFrames).toHaveLength(0); - expect(capturePage).toHaveBeenCalledOnce(); - yield* manager.openPictureInPicture("tab_recording_then_pip"); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledOnce(); + const grants: Array<{ video?: unknown }> = []; + const takeGrant = () => + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { + grants.push(value); + }); - yield* TestClock.adjust(100); + yield* manager.startRecording("tab_1"); + takeGrant(); + yield* manager.startRecording("tab_2"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(recordingFrames).toHaveLength(0); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_recording_then_pip"); - yield* manager.stopRecording("tab_recording_then_pip"); + expect(firstCapturePage).toHaveBeenCalledOnce(); + expect(secondCapturePage).toHaveBeenCalledOnce(); + expect(firstSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + expect(secondSendCommand).not.toHaveBeenCalledWith( + "Page.startScreencast", + expect.anything(), + ); + + yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], { + concurrency: 2, + discard: true, + }); }), ), ); - effectIt.effect("stops frame capture when the native picture-in-picture window closes", () => + effectIt.effect("only lets the host frame that armed a recording claim its stream", () => withManager((manager) => Effect.gen(function* () { - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("native-close-preview-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - const states: PreviewManager.PreviewTabState[] = []; - yield* manager.subscribeStateChanges((_tabId, state) => - Effect.sync(() => { - states.push(state); - }), - ); - - yield* manager.createTab("tab_native_pip_close"); - yield* manager.registerWebview("tab_native_pip_close", 42); - yield* manager.openPictureInPicture("tab_native_pip_close"); + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); - pictureInPictureWindow.close(); - yield* settle(() => states.at(-1)?.pictureInPicture === false); + yield* manager.startRecording("tab_race_a"); + takeGrant({ frameTreeNodeId: 999 }); + takeGrant(); - expect(states.at(-1)?.pictureInPicture).toBe(false); - const capturesAfterClose = capturePage.mock.calls.length; - yield* TestClock.adjust(200); - expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + expect(grants).toEqual([{}, { video: { routingId: 41 } }]); + yield* manager.stopRecording("tab_race_a"); }), ), ); - effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () => + effectIt.effect("reclaims the arm slot from a destroyed webContents", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("retry-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - send.mockImplementationOnce(() => { - throw new Error("picture-in-picture delivery failed"); - }); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; - }); - - yield* manager.createTab("tab_pip_delivery_retry"); - yield* manager.registerWebview("tab_pip_delivery_retry", 42); - yield* manager.openPictureInPicture("tab_pip_delivery_retry"); - expect(send).toHaveBeenCalledOnce(); + const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); - yield* TestClock.adjust(100); + yield* manager.startRecording("tab_race_a"); + destroy(41); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_delivery_retry"); + yield* manager.stopRecording("tab_race_b"); }), ), ); - effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () => + effectIt.effect("gates recording decorations and isolates failed subscribers", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("reloaded-preview-frame"); - const capturePage = vi.fn(async () => ({ - toJPEG: () => jpeg, - toBitmap: () => jpeg, - getSize: () => ({ width: 1280, height: 720 }), - })); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send, webContentsListeners } = - makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const callbacks = new Map< + string, + (event: unknown, input: unknown) => Fiber.Fiber | undefined + >(); + const contents = Object.assign( + makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })), + { + ipc: { + on: ( + channel: string, + callback: (event: unknown, input: unknown) => Fiber.Fiber | undefined, + ) => callbacks.set(channel, callback), + off: vi.fn(), + }, + }, + ); + fromId.mockReturnValue(contents as never); + yield* manager.createTab("tab_recording_input"); + yield* manager.registerWebview("tab_recording_input", 42); + const received: DesktopPreviewRecordingInputEvent[] = []; + yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable")); + yield* manager.subscribeRecordingInputs((event) => + Effect.sync(() => { + received.push(event); + }), + ); + const send = (input: unknown) => + Effect.gen(function* () { + const fiber = callbacks.get("preview:recording-input")?.(null, input); + if (fiber) yield* Fiber.join(fiber); + }); + const key = { type: "key", label: "⌘C", held: true, width: 800 }; + const pointer = { + type: "pointer", + phase: "down", + x: 120, + y: 80, + width: 800, + height: 600, + }; + yield* send(key); + expect(received).toEqual([]); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: true, + showMousePresses: false, }); - - yield* manager.createTab("tab_pip_reload"); - yield* manager.registerWebview("tab_pip_reload", 42); - yield* manager.openPictureInPicture("tab_pip_reload"); - expect(send).toHaveBeenCalledOnce(); - - webContentsListeners.get("did-finish-load")?.(); - yield* TestClock.adjust(100); - - expect(send).toHaveBeenCalledTimes(2); - yield* manager.closePictureInPicture("tab_pip_reload"); + yield* send(key); + yield* send(pointer); + yield* send({ ...key, width: 0 }); + expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]); + yield* manager.stopRecording("tab_recording_input"); + yield* send(key); + expect(received).toHaveLength(1); + yield* manager.startRecording("tab_recording_input", { + showKeyPresses: false, + showMousePresses: true, + }); + yield* send(key); + yield* send(pointer); + expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer }); + expect(received).toHaveLength(2); }), ), ); - effectIt.effect("encodes a picture-in-picture frame only when the page changes", () => + effectIt.effect("continues native recording when the source warmup fails", () => withManager((manager) => Effect.gen(function* () { - let page = "static-page"; - let pixelsReadable = true; - const toJPEG = vi.fn((pixels: string) => Buffer.from(`jpeg:${pixels}`)); const capturePage = vi.fn(async () => { - const pixels = page; - return { - toJPEG: () => toJPEG(pixels), - toBitmap: () => (pixelsReadable ? Buffer.from(pixels) : Buffer.alloc(0)), - getSize: () => ({ width: 1280, height: 720 }), - }; + throw new Error("source is not ready"); }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const host = makeTestHostWebContents(); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), }); + fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_pip_unchanged"); - yield* manager.registerWebview("tab_pip_unchanged", 42); - yield* manager.openPictureInPicture("tab_pip_unchanged"); - yield* TestClock.adjust(100); - - expect(capturePage).toHaveBeenCalledTimes(2); - expect(toJPEG).toHaveBeenCalledOnce(); - expect(send).toHaveBeenCalledOnce(); - - page = "changed-page"; - yield* TestClock.adjust(100); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - expect(toJPEG).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenLastCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ data: Buffer.from("jpeg:changed-page").toString("base64") }), - ); + yield* manager.startRecording("tab_recording_warmup_failure"); + expect(capturePage).toHaveBeenCalledTimes(2); - // A frame whose pixels cannot be read has no fingerprint and is still delivered. - pixelsReadable = false; - yield* TestClock.adjust(100); + const handler = host.displayMediaHandler(); + const streams: Array<{ video?: unknown }> = []; + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); - expect(toJPEG).toHaveBeenCalledTimes(3); - expect(send).toHaveBeenCalledTimes(3); - yield* manager.closePictureInPicture("tab_pip_unchanged"); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); - effectIt.effect("drops empty frames before picture-in-picture delivery", () => + effectIt.effect("serializes recording source acquisition with webview replacement", () => withManager((manager) => Effect.gen(function* () { - const validImage: TestCapturedPreviewImage = { - toJPEG: () => Buffer.from("valid-preview-frame"), - toBitmap: () => Buffer.from("valid-preview-frame"), + const capturedImage = { + toJPEG: () => Buffer.from("unused-recording-frame"), getSize: () => ({ width: 1280, height: 720 }), }; - const capturePage = vi.fn(async () => validImage); - capturePage.mockResolvedValueOnce({ - toJPEG: () => Buffer.from("empty-preview-frame"), - getSize: () => ({ width: 0, height: 0 }), + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ) + .mockResolvedValue(capturedImage); + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementOn = vi.fn(); + const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { + on: replacementOn, + }); + fromId.mockImplementation((id) => { + if (id === 42) return initialWebContents; + if (id === 43) return replacementWebContents; + return null; }); - yield* manager.createTab("tab_empty_frame"); - yield* manager.registerWebview("tab_empty_frame", 42); - yield* manager.openPictureInPicture("tab_empty_frame"); - - expect(capturePage).toHaveBeenCalledOnce(); - expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled(); - expect(send).not.toHaveBeenCalled(); - - yield* TestClock.adjust(100); + yield* manager.createTab("tab_recording_replacement_race"); + yield* manager.registerWebview("tab_recording_replacement_race", 42); + const start = yield* manager + .startRecording("tab_recording_replacement_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + const replacement = yield* manager + .registerWebview("tab_recording_replacement_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementOn).not.toHaveBeenCalled(); - expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]); - expect(send).toHaveBeenCalledOnce(); - yield* manager.closePictureInPicture("tab_empty_frame"); + finishWarmup(capturedImage); + yield* Fiber.join(start); + yield* Fiber.join(replacement); + expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + ]); }), ), ); @@ -4744,82 +2376,6 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("settles the pick when the annotation screenshot never arrives", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - - yield* manager.setAnnotationSendEnabled("tab_1", true); - onPicked?.( - {}, - { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }, - null, - "send", - ); - yield* Effect.yieldNow; - expect(pick.pollUnsafe()).toBeUndefined(); - - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(pick); - expect(result?.annotation.screenshot).toBeNull(); - expect(result?.screenshotFailed).toBe(true); - expect(result?.submission).toBe("send"); - expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured"); - }), - ), - ); - describe("annotation screenshots", () => { const captureAnnotation = ( cropRect: PreviewAnnotationRect | null, @@ -4891,23 +2447,6 @@ describe("PreviewManager", () => { }), ); - effectIt.effect("crops an on-screen region at device scale", () => - Effect.gen(function* () { - const rect = { x: 5, y: 6, width: 20, height: 30 }; - const { result, crop } = yield* captureAnnotation(rect, { - regions: [{ id: "region_1", rect }], - }); - expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); - expect(result?.screenshotFailed).toBeUndefined(); - expect(result?.annotation.screenshot).toEqual({ - dataUrl: "data:image/png;base64,cropped", - width: 40, - height: 60, - cropRect: rect, - }); - }), - ); - effectIt.effect("clips a partially visible selection to the captured viewport", () => Effect.gen(function* () { const { result, crop } = yield* captureAnnotation( @@ -4957,6 +2496,7 @@ describe("PreviewManager", () => { }), ); + // regression: #18441 zero-area strokes on the viewport edge must still get a screenshot crop effectIt.effect.each([ { name: "zero-width SVG line on the left edge", @@ -5001,171 +2541,8 @@ describe("PreviewManager", () => { }); }), ); - - effectIt.effect.each(["viewport", "crop"] as const)( - "reports an empty native %s image as a failed screenshot", - (emptyImage) => - Effect.gen(function* () { - const rect = { x: 5, y: 6, width: 20, height: 30 }; - const { result, crop } = yield* captureAnnotation( - rect, - { - regions: [{ id: "region_1", rect }], - }, - emptyImage, - ); - if (emptyImage === "viewport") expect(crop).not.toHaveBeenCalled(); - else - expect(crop).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 12, width: 40, height: 60 }); - expect(result?.annotation.screenshot).toBeNull(); - expect(result?.screenshotFailed).toBe(true); - }), - ); }); - effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => - withManager((manager) => - Effect.gen(function* () { - let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - isFocused: () => true, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - once: vi.fn(), - off: vi.fn(), - capturePage: vi.fn(() => new Promise(() => {})), - ipc: { - on: vi.fn((channel: string, listener: typeof onPicked) => { - if (channel === "preview:element-picked") onPicked = listener; - }), - off: vi.fn(), - removeListener: vi.fn(), - }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - const annotation = { - id: "annotation_1", - pageUrl: "https://example.com", - pageTitle: "Example", - comment: "Tighten this spacing", - elements: [], - regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], - strokes: [], - styleChanges: [], - screenshot: null, - createdAt: "2026-06-11T00:00:00.000Z", - }; - - yield* manager.createTab("tab_1"); - yield* manager.registerWebview("tab_1", 42); - const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - onPicked?.({}, annotation, null, "send"); - yield* Effect.yieldNow; - - const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); - yield* Effect.yieldNow; - expect(yield* Fiber.join(firstPick)).toBeNull(); - webviewSend.mockClear(); - - yield* TestClock.adjust("6 seconds"); - yield* Effect.yieldNow; - expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); - expect(secondPick.pollUnsafe()).toBeUndefined(); - - onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach"); - yield* TestClock.adjust("6 seconds"); - const result = yield* Fiber.join(secondPick); - expect(result?.annotation.id).toBe("annotation_2"); - expect(result?.submission).toBe("attach"); - }), - ), - ); - - effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => - withManager((manager) => - Effect.gen(function* () { - let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; - const goBack = vi.fn(); - const goForward = vi.fn(); - let canGoBack = true; - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoadingMainFrame: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { - on: vi.fn((channel: string, listener: typeof mouseNavigate) => { - if (channel === "preview:mouse-navigate") mouseNavigate = listener; - }), - off: vi.fn(), - }, - send: webviewSend, - navigationHistory: { - canGoBack: () => canGoBack, - canGoForward: () => true, - goBack, - goForward, - }, - setIgnoreMenuShortcuts: vi.fn(), - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand: vi.fn(async () => undefined), - on: vi.fn(), - off: vi.fn(), - }, - } as never); - - yield* manager.createTab("tab_nav"); - yield* manager.registerWebview("tab_nav", 42); - expect(mouseNavigate).toBeDefined(); - - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "forward" }); - yield* Effect.yieldNow; - expect(goForward).toHaveBeenCalledOnce(); - - mouseNavigate?.({}, { direction: "sideways" }); - canGoBack = false; - mouseNavigate?.({}, { direction: "back" }); - yield* Effect.yieldNow; - expect(goBack).toHaveBeenCalledOnce(); - }), - ), - ); - effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { @@ -5187,71 +2564,4 @@ describe("PreviewManager", () => { }), ), ); - - effectIt.effect("copies screenshot artifacts to the system clipboard", () => - withManager((manager) => - Effect.gen(function* () { - const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png"; - - yield* manager.copyArtifactToClipboard(artifactPath); - - expect(createFromPath).toHaveBeenCalledWith(artifactPath); - expect(clipboardItemConstructor).toHaveBeenCalledWith({ - "image/png": expect.any(Blob), - }); - expect(writeClipboard).toHaveBeenCalledOnce(); - const exit = yield* Effect.exit( - manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isSuccess(exit)) return; - const error = Option.getOrThrow(Cause.findErrorOption(exit.cause)); - expect(error).toMatchObject({ - _tag: "PreviewArtifactPathOutsideDirectoryError", - artifactPath: "/tmp/t3/dev/settings.json", - artifactDirectory: "/tmp/t3/dev/browser-artifacts", - }); - expect("cause" in error).toBe(false); - - createFromPath.mockReturnValueOnce({ - isEmpty: () => true, - toPNG: () => Buffer.from("invalid"), - }); - const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(invalidImageExit)).toBe(true); - if (Exit.isSuccess(invalidImageExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({ - _tag: "PreviewArtifactImageLoadError", - artifactPath, - }); - - const writeCause = new Error("clipboard write failed"); - writeClipboard.mockRejectedValueOnce(writeCause); - const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath)); - expect(Exit.isFailure(writeExit)).toBe(true); - if (Exit.isSuccess(writeExit)) return; - expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({ - _tag: "PreviewOperationError", - operation: "copyArtifactToClipboard.write", - artifactPath, - cause: writeCause, - }); - }), - ), - ); -}); - -describe("PreviewOperationError", () => { - it("keeps timeline detail separate from its structured message", () => { - const cause = new Error("CDP command failed with an invalid node id"); - const error = new PreviewManager.PreviewOperationError({ - operation: "click.DOM.resolveNode", - tabId: "tab_1", - webContentsId: 42, - cause, - }); - - expect(error.message).not.toContain(cause.message); - expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message); - }); }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 17d8990fe170..e9578c711ae4 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -206,7 +206,7 @@ export const fitPictureInPictureContentSize = ( return [Math.round(width), Math.round(height)]; }; -export const recordingFileExtension = (mimeType: string): string => { +const recordingFileExtension = (mimeType: string): string => { const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); return extension || "video"; @@ -391,8 +391,7 @@ interface ManagedListeners { readonly webContents: Electron.WebContents; } -// Each still-image request owns a separate, short-lived rendering lease. -type FrameCaptureConsumer = "picture-in-picture" | "recording" | symbol; +type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { readonly recordingInputOptions?: RecordingInputOptions; @@ -496,7 +495,7 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; -export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => +const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && (input.meta || input.control) && @@ -2488,7 +2487,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - captureStillImage( + capturePageWithRetry( { operation: "captureScreenshot.capturePage", tabId, @@ -2536,104 +2535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); - const captureStillImage = Effect.fn("PreviewManager.captureStillImage")(function* ( - errorContext: PreviewOperationContext, - tabId: string, - wc: Electron.WebContents, - ) { - const consumer = Symbol("still-image"); - return yield* Effect.acquireUseRelease( - startFrameCapture(tabId, consumer), - () => - Effect.gen(function* () { - // A DOM mutation can precede its compositor frame even in a shown - // window. Keep the original guest rendering through the next paint; - // a hidden guest otherwise returns old pixels or never completes. - const probe = Effect.fn("PreviewManager.probeStillImagePaint")(function* ( - send: ( - method: string, - params?: Record, - ) => Effect.Effect, - ) { - // CDP executes against the current document without Electron's - // did-stop-loading gate. Isolate scheduling globals from page code. - const tree = (yield* send("Page.getFrameTree")) as - | { frameTree?: { frame?: { id?: string } } } - | undefined; - const frameId = tree?.frameTree?.frame?.id; - if (typeof frameId !== "string") - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint frame is unavailable."), - }); - const world = (yield* send("Page.createIsolatedWorld", { - frameId, - worldName: "t3-preview-still-image", - })) as { executionContextId?: number } | undefined; - if (typeof world?.executionContextId !== "number") - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint context is unavailable."), - }); - const response = (yield* send("Runtime.evaluate", { - // Settle and release scheduled work when only timers keep running. - // The outer deadline still bounds a frozen renderer. - expression: `new Promise(resolve => { let frame; const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, ${CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS}); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { clearTimeout(timer); resolve(true); }); }); })`, - contextId: world.executionContextId, - awaitPromise: true, - returnByValue: true, - })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; - if (response?.exceptionDetails || response?.result?.value !== true) - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint boundary was not confirmed."), - }); - }); - const paintReady = Effect.gen(function* () { - // Only use an existing owned session; never attach or displace - // another debugger to obtain a screenshot. - const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); - if (!control) - return yield* new PreviewOperationError({ - ...errorContext, - cause: new Error("The preview paint session is unavailable."), - }); - return yield* control.semaphore.withPermit( - probe((method, params) => - attemptPromise(errorContext, () => control.debugger.sendCommand(method, params)), - ), - ); - }).pipe( - Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), - Effect.catch((error) => - Effect.logWarning( - "Preview paint readiness was not confirmed; capturing available pixels with mutation freshness unconfirmed.", - { tabId, webContentsId: wc.id, error }, - ), - ), - ); - // capturePage temporarily marks the original guest as captured. A - // minimized page can otherwise keep animation frames suspended even - // with scheduler throttling disabled. Discard this warmup image and - // accept its output only after readiness settles. If the bounded - // probe cannot confirm paint, retain the native capture fallback. - const warmup = capturePageWithRetry(errorContext, tabId, wc).pipe( - Effect.catchTags({ - PreviewOperationError: (error) => - Effect.logWarning("Preview warmup capture failed; continuing with final capture.", { - tabId, - webContentsId: wc.id, - error, - }), - }), - ); - yield* Effect.all([paintReady, warmup], { concurrency: 2, discard: true }); - return yield* capturePageWithRetry(errorContext, tabId, wc); - }), - () => stopFrameCapture(tabId, consumer), - ); - }); - const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( tabId: string, ) { diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 8cbaad7f38dc..3b72ab589075 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -16,17 +16,6 @@ const assertionData = { extensions: { prf: { results: { first: base64Url([9]) } } }, }; -const attestationData = { - credentialId: base64Url([1, 2, 3]), - clientDataJSON: base64Url([4]), - attestationObject: base64Url([10]), - authData: base64Url([11]), - publicKey: base64Url([12]), - publicKeyAlgorithm: -7, - transports: [], - extensions: { credProps: { rk: true } }, -}; - const creationOptions: PublicKeyCredentialCreationOptions = { challenge: new Uint8Array([1]), rp: { name: "Example" }, @@ -144,62 +133,6 @@ describe("installPasskeyBridge", () => { }); }); - it("returns registrations with their attestation and public key", async () => { - const { invoke, credentials, FakeAttestationResponse } = install(() => ({ - success: true, - data: attestationData, - })); - - const credential = (await credentials.create({ - publicKey: creationOptions, - })) as PublicKeyCredential; - - assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL); - const response = credential.response as AuthenticatorAttestationResponse; - assert.instanceOf(response, FakeAttestationResponse); - assert.isNull(credential.authenticatorAttachment); - assert.deepStrictEqual(bytesOf(response.attestationObject), [10]); - assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]); - assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]); - assert.strictEqual(response.getPublicKeyAlgorithm(), -7); - assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } }); - assert.deepStrictEqual(credential.toJSON(), { - id: attestationData.credentialId, - rawId: attestationData.credentialId, - type: "public-key", - response: { - clientDataJSON: attestationData.clientDataJSON, - attestationObject: attestationData.attestationObject, - authenticatorData: attestationData.authData, - transports: [], - publicKey: attestationData.publicKey, - publicKeyAlgorithm: -7, - }, - clientExtensionResults: { credProps: { rk: true } }, - }); - }); - - it("leaves autofill and non-passkey requests to Chromium", async () => { - const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({ - success: true, - data: assertionData, - })); - - await credentials.get({ - mediation: "conditional", - publicKey: { challenge: new Uint8Array([1]) }, - }); - await credentials.get({ password: true } as CredentialRequestOptions); - const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; - await credentials.create(upgrade); - await credentials.create({ password: {} } as CredentialCreationOptions); - assert.strictEqual(invoke.mock.calls.length, 0); - assert.strictEqual(nativeGet.mock.calls.length, 2); - assert.strictEqual(nativeCreate.mock.calls.length, 2); - assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable()); - assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); - }); - it("rejects with the WebAuthn error the ceremony ended with", async () => { const { credentials } = install((channel) => ({ success: false, @@ -216,18 +149,6 @@ describe("installPasskeyBridge", () => { assert.instanceOf(fetched, TypeError); }); - it("refuses when the main process has no handler for the page", async () => { - const { credentials } = install(() => { - throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered"); - }); - - const error = await credentials - .get({ publicKey: { challenge: new Uint8Array([1]) } }) - .catch((caught: unknown) => caught); - assert.instanceOf(error, DOMException); - assert.strictEqual((error as DOMException).name, "NotAllowedError"); - }); - it("rejects as soon as the page aborts, without waiting on the system sheet", async () => { const { credentials } = install(() => new Promise(() => {})); const controller = new AbortController(); diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index c6b8720314a3..f848aff47b8f 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -257,9 +257,13 @@ describe("PreviewPasskeys", () => { page.state.focused = true; let finish: (value: unknown) => void = () => {}; - webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const ceremonyStarted = Promise.withResolvers(); + webauthn.getCredential.mockImplementationOnce(() => { + ceremonyStarted.resolve(); + return new Promise((resolve) => (finish = resolve)); + }); const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey)); - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + yield* Effect.promise(() => ceremonyStarted.promise); assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); page.mainFrame.origin = "https://other.example"; @@ -268,26 +272,6 @@ describe("PreviewPasskeys", () => { }).pipe(Effect.provide(bridgeLayer)), ); - it.effect("answers the page even when the native ceremony never settles", () => - Effect.gen(function* () { - const passkeys = yield* PreviewPasskeys.PreviewPasskeys; - const { guest, call } = makeGuest(); - passkeys.attachGuest(guest); - webauthn.getCredential.mockReturnValueOnce(new Promise(() => {})); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - - const pending = yield* Effect.forkChild( - call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }), - ); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); - - assert.deepStrictEqual(yield* Fiber.join(pending), { - success: false, - error: "NotAllowedError", - }); - }).pipe(Effect.provide(bridgeLayer)), - ); - it.effect("turns each path on only for builds signed for it", () => Effect.gen(function* () { const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe( diff --git a/apps/desktop/src/preview/PickedElementPayload.test.ts b/apps/desktop/src/preview/PickedElementPayload.test.ts index d7a967324771..68f313783a27 100644 --- a/apps/desktop/src/preview/PickedElementPayload.test.ts +++ b/apps/desktop/src/preview/PickedElementPayload.test.ts @@ -31,76 +31,6 @@ function validPayload(overrides?: Record): Record { - it("accepts a complete, well-typed payload", () => { - expect(isPickedElementPayload(validPayload())).toBe(true); - }); - - it("accepts nullable string fields when null", () => { - expect( - isPickedElementPayload( - validPayload({ pageTitle: null, selector: null, componentName: null, source: null }), - ), - ).toBe(true); - }); - - it("accepts an empty stack array", () => { - expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true); - }); - - it("accepts stack frames with null fields", () => { - expect( - isPickedElementPayload( - validPayload({ - stack: [ - { - functionName: null, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - ], - }), - ), - ).toBe(true); - }); - - it("rejects null and primitive inputs", () => { - expect(isPickedElementPayload(null)).toBe(false); - expect(isPickedElementPayload(undefined)).toBe(false); - expect(isPickedElementPayload("string")).toBe(false); - expect(isPickedElementPayload(42)).toBe(false); - expect(isPickedElementPayload([])).toBe(false); - }); - - it.each<[string, Record]>([ - ["missing pageUrl", validPayload({ pageUrl: undefined })], - ["wrong-type pageUrl", validPayload({ pageUrl: 123 })], - ["missing tagName", validPayload({ tagName: undefined })], - ["missing htmlPreview", validPayload({ htmlPreview: undefined })], - ["missing styles", validPayload({ styles: undefined })], - ["missing pickedAt", validPayload({ pickedAt: undefined })], - ["wrong-type pageTitle", validPayload({ pageTitle: 99 })], - ["wrong-type selector", validPayload({ selector: 99 })], - ["wrong-type componentName", validPayload({ componentName: 99 })], - ])("rejects payloads with %s", (_label, value) => { - expect(isPickedElementPayload(value)).toBe(false); - }); - - it("rejects malformed source frames", () => { - expect( - isPickedElementPayload( - validPayload({ - source: { - functionName: 0, - fileName: null, - lineNumber: null, - columnNumber: null, - }, - }), - ), - ).toBe(false); - }); - it("rejects non-finite numeric line/column numbers", () => { expect( isPickedElementPayload( @@ -127,11 +57,6 @@ describe("isPickedElementPayload", () => { ), ).toBe(false); }); - - it("rejects malformed stack arrays", () => { - expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false); - expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); - }); }); function validAnnotation(overrides?: Record): Record { @@ -176,26 +101,9 @@ function validAnnotation(overrides?: Record): Record { - it("accepts a structured annotation draft before screenshot capture", () => { - expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); - }); - it("rejects screenshots supplied by the guest preload", () => { expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( false, ); }); - - it("rejects malformed geometry and nested element payloads", () => { - expect( - isPreviewAnnotationPayload( - validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), - ), - ).toBe(false); - expect( - isPreviewAnnotationPayload( - validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), - ), - ).toBe(false); - }); }); diff --git a/apps/desktop/src/preview/RecordingInput.test.ts b/apps/desktop/src/preview/RecordingInput.test.ts index 062685ec2934..0f4f93fb36bf 100644 --- a/apps/desktop/src/preview/RecordingInput.test.ts +++ b/apps/desktop/src/preview/RecordingInput.test.ts @@ -1,39 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { recordingKeyLabel, recordingKeysAreSensitive } from "./RecordingInput.ts"; - -const key = ( - value: string, - modifiers: Partial<{ - metaKey: boolean; - ctrlKey: boolean; - altKey: boolean; - shiftKey: boolean; - }> = {}, -) => ({ - key: value, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - ...modifiers, -}); - -describe("recording key labels", () => { - it("formats macOS and other-platform shortcuts", () => { - expect(recordingKeyLabel(key("c", { metaKey: true }), true)).toBe("⌘C"); - expect(recordingKeyLabel(key("c", { ctrlKey: true }), false)).toBe("Ctrl + C"); - expect(recordingKeyLabel(key("Tab", { altKey: true, shiftKey: true }), true)).toBe("⌥⇧⇥"); - }); - it("shows held modifiers once and labels navigation keys", () => { - expect(recordingKeyLabel(key("Meta", { metaKey: true }), true)).toBe("⌘"); - expect(recordingKeyLabel(key("Shift", { shiftKey: true }), false)).toBe("Shift"); - expect(recordingKeyLabel(key("ArrowLeft"), true)).toBe("←"); - expect(recordingKeyLabel(key(" "), false)).toBe("Space"); - }); - it.each(["Dead", "Unidentified", "Process", ""])("excludes composition key %s", (value) => { - expect(recordingKeyLabel(key(value), true)).toBeNull(); - }); -}); +import { recordingKeysAreSensitive } from "./RecordingInput.ts"; describe("recording key privacy", () => { const field = (type: string) => ({ tagName: "INPUT", getAttribute: () => type }); diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 94681b3e046f..182b6a505d16 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -20,12 +20,6 @@ function parseWebPreferences(input: string): Record { describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); - it("contains exactly the three security-critical keys", () => { - expect(Object.keys(parsed).toSorted()).toEqual( - ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(), - ); - }); - it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so // `contextIsolation="no"` would silently leave isolation ENABLED. Lock @@ -35,19 +29,7 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { } }); - it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => { - expect(parsed["contextIsolation"]).toBe("false"); - }); - it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => { expect(parsed["sandbox"]).toBe("true"); }); - - it("disables nodeIntegration (defense in depth — page never gets Node)", () => { - expect(parsed["nodeIntegration"]).toBe("false"); - }); - - it("contains no whitespace (Electron's parser does not trim)", () => { - expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); - }); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 7313b9b859e3..832a77457714 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -109,124 +108,6 @@ describe("DesktopSettings", () => { }), ), ); - it.effect("loads defaults when no settings file exists", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - assert.deepEqual(yield* settings.get, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS); - }), - ), - ); - - it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual( - DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), - { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings, - ); - }); - - it.effect("loads persisted settings and applies semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - linuxPasswordStore: "gnome-libsecret", - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "gnome-libsecret", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isTrue(exposure.changed); - assert.equal(exposure.settings.serverExposureMode, "local-only"); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: true, - port: Option.some(9443), - }); - assert.isTrue(tailscale.changed); - assert.equal(tailscale.settings.tailscaleServePort, 9443); - - const updateChannel = yield* settings.setUpdateChannel("nightly"); - assert.isTrue(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannel, "nightly"); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); - }), - ), - ); - - it.effect("reports the failed desktop settings write operation and path", () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.desktopSettingsPath, { recursive: true }); - - const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip); - assert.instanceOf(error, DesktopAppSettings.DesktopSettingsWriteError); - assert.equal(error.operation, "replace-settings-file"); - assert.equal(error.path, environment.desktopSettingsPath); - assert.exists(error.cause); - assert.equal( - error.message, - `Desktop settings write failed during replace-settings-file at ${environment.desktopSettingsPath}.`, - ); - }), - ), - ); - - it.effect("does not persist no-op semantic updates", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - - const exposure = yield* settings.setServerExposureMode("local-only"); - assert.isFalse(exposure.changed); - - const tailscale = yield* settings.setTailscaleServe({ - enabled: false, - port: Option.none(), - }); - assert.isFalse(tailscale.changed); - - const updateChannel = yield* settings.setUpdateChannel("latest"); - assert.isFalse(updateChannel.changed); - assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); - }), - ), - ); it.effect("falls back to defaults when the settings file is malformed", () => withSettings( @@ -283,7 +164,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ - mainWindowBounds: { x: 10, y: 20, width: 599, height: 620 }, + mainWindowBounds: { x: 10.5, y: 20, width: 839, height: 620 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", }); @@ -296,45 +177,6 @@ describe("DesktopSettings", () => { ), ); - it.effect( - "normalizes unsupported linux password-store values without dropping other settings", - () => - withSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString( - environment.desktopSettingsPath, - `{ - "linuxPasswordStore": "unsupported-store", - "serverExposureMode": "network-accessible", - "tailscaleServeEnabled": true, - "tailscaleServePort": 8443, - "updateChannel": "nightly", - "updateChannelConfiguredByUser": true - }\n`, - ); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -342,24 +184,17 @@ describe("DesktopSettings", () => { const fileSystem = yield* FileSystem.FileSystem; const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 600, height: 620 }, true); + yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { - mainWindowBounds: { x: -1200, y: 40, width: 600, height: 620 }, + mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", } satisfies typeof DesktopSettingsPatch.Type); - - const restored = yield* Effect.gen(function* () { - const freshSettings = yield* DesktopAppSettings.DesktopAppSettings; - return yield* freshSettings.load; - }).pipe(Effect.provide(DesktopAppSettings.layer)); - assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds); - assert.isTrue(restored.mainWindowMaximized); }), ), ); @@ -449,33 +284,6 @@ describe("DesktopSettings", () => { ), ); - it.effect("normalizes invalid persisted Tailscale Serve ports", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }); - - assert.deepEqual(yield* settings.load, { - linuxPasswordStore: "auto", - localEnvironmentEnabled: true, - mainWindowBounds: null, - mainWindowMaximized: false, - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - wslBackendEnabled: false, - wslOnly: false, - wslDistro: null, - } satisfies DesktopAppSettings.DesktopSettings); - }), - ), - ); - it.effect("persists wsl backend toggle and normalizes invalid distro names", () => withSettings( Effect.gen(function* () { @@ -550,19 +358,4 @@ describe("DesktopSettings", () => { }), ), ); - - it.effect("drops invalid persisted wsl distro values on load", () => - withSettings( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* writeSettingsPatch({ - wslBackendEnabled: true, - wslDistro: "bad/name", - }); - const loaded = yield* settings.load; - assert.equal(loaded.wslBackendEnabled, true); - assert.equal(loaded.wslDistro, null); - }), - ), - ); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a26892d6984a..1c131681295f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -48,8 +48,8 @@ export interface DesktopSettingsChange { } const DEFAULT_TAILSCALE_SERVE_PORT = 443; -export const MIN_MAIN_WINDOW_SIZE = { - width: 600, +const MIN_MAIN_WINDOW_SIZE = { + width: 840, height: 620, } as const; export const DesktopWindowBoundsSchema = Schema.Struct({ diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts deleted file mode 100644 index 41ba53cf1d43..000000000000 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; - -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopClientSettings from "./DesktopClientSettings.ts"; - -interface LogRecord { - readonly message: unknown; - readonly annotations: Readonly>; -} - -const baseDir = "/virtual-home"; - -function layer(fileSystemLayer: Layer.Layer) { - const layerEnvironment = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: baseDir, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), - ), - ); - - return DesktopClientSettings.layer.pipe( - Layer.provideMerge(Layer.mergeAll(layerEnvironment, NodeServices.layer, fileSystemLayer)), - ); -} - -const readWithLogs = (fileSystemLayer: Layer.Layer) => { - const records: Array = []; - const logger = Logger.make(({ fiber, message }) => { - records.push({ - message, - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - return { - result: yield* Effect.result(settings.get), - settingsPath: environment.clientSettingsPath, - records, - }; - }).pipe( - Effect.provide( - Layer.mergeAll(layer(fileSystemLayer), Logger.layer([logger], { mergeWithExisting: false })), - ), - ); -}; - -describe("DesktopClientSettings diagnostics", () => { - it.effect("treats a missing settings file as expected without warning", () => - Effect.gen(function* () { - const result = yield* readWithLogs(FileSystem.layerNoop({})); - - if (result.result._tag !== "Success") return assert.fail("expected a successful read"); - assert.isTrue(Option.isNone(result.result.success)); - assert.deepEqual(result.records, []); - }), - ); - - it.effect("reports non-missing filesystem failures and logs the settings path", () => { - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: `${baseDir}/userdata/client-settings.json`, - }); - - return Effect.gen(function* () { - const result = yield* readWithLogs( - FileSystem.layerNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - - if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); - assert.instanceOf( - result.result.failure, - DesktopClientSettings.DesktopClientSettingsReadError, - ); - assert.strictEqual(result.result.failure.cause, permissionError); - assert.equal(result.records.length, 1); - assert.deepEqual(result.records[0]?.message, [ - "Could not read desktop client settings.", - permissionError, - ]); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); - }); - }); - - it.effect("reports malformed settings documents and logs the settings path", () => - Effect.gen(function* () { - const result = yield* readWithLogs( - FileSystem.layerNoop({ - readFileString: () => Effect.succeed("{not-json"), - }), - ); - - if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); - assert.instanceOf( - result.result.failure, - DesktopClientSettings.DesktopClientSettingsReadError, - ); - assert.equal(result.result.failure.operation, "decode-document"); - assert.equal(result.records.length, 1); - const message = result.records[0]?.message; - if (!Array.isArray(message)) { - return assert.fail("expected structured warning arguments"); - } - assert.equal(message[0], "Could not decode desktop client settings."); - const schemaError = message[1]; - if (schemaError === null || typeof schemaError !== "object") { - return assert.fail("expected the schema error in the warning"); - } - assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); - }), - ); -}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 75ff38f32260..51c3d31daae8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -75,9 +75,6 @@ const clientSettings: ClientSettings = { }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); -const decodeRecordJson = Schema.decodeEffect( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); function layer(baseDir: string) { const layerEnvironment = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -113,42 +110,6 @@ const withClientSettings = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopClientSettings", () => { - it.effect("returns none when no client settings file exists", () => - withClientSettings( - Effect.gen(function* () { - const settings = yield* DesktopClientSettings.DesktopClientSettings; - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); - - it.effect("persists and reloads client settings", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* settings.set(clientSettings); - - assert.deepEqual(yield* settings.get, Option.some(clientSettings)); - assert.deepEqual( - yield* decodeClientSettingsJson( - yield* fileSystem.readFileString(environment.clientSettingsPath), - ), - clientSettings, - ); - assert.isFalse( - Object.hasOwn( - yield* decodeRecordJson( - yield* fileSystem.readFileString(environment.clientSettingsPath), - ), - "settings", - ), - ); - }), - ), - ); - it.effect("saves through a symlinked client settings file without replacing the link", () => withClientSettings( Effect.gen(function* () { @@ -228,29 +189,6 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("reports the failed client settings write operation and path", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true }); - - const error = yield* settings.set(clientSettings).pipe(Effect.flip); - assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); - assert.equal(error.operation, "replace-settings-file"); - assert.equal(error.path, environment.clientSettingsPath); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.isString(error.cause.stack); - assert.equal( - error.message, - `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, - ); - assert.notInclude(error.message, error.cause.message); - }), - ), - ); - it.effect("loads lenient direct client settings documents", () => withClientSettings( Effect.gen(function* () { @@ -300,23 +238,8 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("loads defaults from empty client settings documents", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); - - assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); - }), - ), - ); - it.effect.each([ { label: "malformed JSON", contents: "{not-json" }, - { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, ])("reports $label without treating the settings file as absent", (document) => withClientSettings( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 27ec38f46c59..0001548f4534 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -4,10 +4,8 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/co import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -155,17 +153,6 @@ const withSavedEnvironments = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopSavedEnvironments", () => { - it.effect("reads saved environment metadata", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(); - - assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); - }), - ), - ); - it.effect("loads lenient saved environment registry documents", () => withSavedEnvironments( Effect.gen(function* () { @@ -216,30 +203,6 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("reports invalid saved secret encoding without exposing the secret", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry("%%%"); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.equal(error.field, "encryptedBearerToken"); - assert.exists(error.cause); - assert.equal( - error.message, - `Failed to decode encryptedBearerToken for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notInclude(error.message, "%%%"); - }), - ), - ); - it.effect("returns no secret while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { @@ -254,114 +217,4 @@ describe("DesktopSavedEnvironments", () => { { availableSecretStorage: false }, ), ); - - it.effect("adds saved-environment context to safe storage availability failures", () => { - const cause = new Error("safe storage unavailable"); - return withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* seedSavedEnvironmentRegistry(Base64.encode(textEncoder.encode("enc:bearer-token"))); - - const error = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - - assert.instanceOf( - error, - DesktopSavedEnvironments.DesktopSavedEnvironmentSecretProtectionError, - ); - assert.equal(error.operation, "check-encryption-availability"); - assert.equal(error.environmentId, savedRegistryRecord.environmentId); - assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); - assert.instanceOf(error.cause, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); - const availabilityError = - error.cause as ElectronSafeStorage.ElectronSafeStorageAvailabilityError; - assert.strictEqual(availabilityError.cause, cause); - assert.equal( - error.message, - `Desktop saved-environment secret protection failed during check-encryption-availability for environment ${savedRegistryRecord.environmentId} at ${environment.savedEnvironmentRegistryPath}.`, - ); - assert.notEqual(error.message, availabilityError.message); - }), - { availabilityError: cause }, - ); - }); - - it.effect("treats empty saved environment documents as empty", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); - - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), - ); - }), - ), - ); - - it.effect("surfaces malformed saved environment documents", () => - withSavedEnvironments( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); - - const registryError = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf( - registryError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); - assert.exists(registryError.cause); - const secretError = yield* savedEnvironments - .getSecret(savedRegistryRecord.environmentId) - .pipe(Effect.flip); - assert.instanceOf( - secretError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); - }), - ), - ); - - it.effect("reports saved environment filesystem reads separately from document decoding", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const baseFileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-saved-environments-test-", - }); - const registryPath = path.join(baseDir, "userdata", "saved-environments.json"); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: registryPath, - }); - const layerFileSystem = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.fail(permissionError), - }), - ); - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( - Effect.provide(layer(baseDir, undefined, layerFileSystem)), - ); - - const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.registryPath, registryPath); - assert.strictEqual(error.cause, permissionError); - assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 6ff500a3eb3e..9d12ed5a07fe 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,23 +1,15 @@ // @effect-diagnostics nodeBuiltinImport:off -- The process fixture runs real shell commands with isolated config files. import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Clock from "effect/Clock"; import { assert, describe, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as TestClock from "effect/testing/TestClock"; import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import { vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -27,10 +19,6 @@ vi.mock("node:os", { spy: true }); const textEncoder = new TextEncoder(); -const isDesktopShellEnvironmentCommandError = Schema.is( - DesktopShellEnvironment.DesktopShellEnvironmentCommandError, -); - function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -82,10 +70,7 @@ function runShellEnvironment(input: { readonly userShell?: string; readonly handler: ( command: ChildProcess.Command, - ) => - | string - | { output: string; exitCode: number } - | Effect.Effect; + ) => string | { output: string; exitCode: number }; readonly failure?: PlatformError.PlatformError; }) { const layerEnvironment = Layer.succeed( @@ -99,7 +84,6 @@ function runShellEnvironment(input: { ChildProcessSpawner.make((command) => { if (input.failure !== undefined) return Effect.fail(input.failure); const result = input.handler(command); - if (Effect.isEffect(result)) return result; return Effect.succeed( typeof result === "string" ? makeProcess(result) @@ -134,77 +118,6 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { - // Explicit opt-in keeps real shell coverage independent of globally installed tools. - it.effect.runIf(process.env.T3_TEST_NU_PATH !== undefined)( - "hydrates the desktop through a renamed Nushell symlink with native export conversions", - () => - Effect.gen(function* () { - const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-nu-")); - try { - const toolPath = NodePath.join(home, "nu tools", "bin"); - const extraToolPath = NodePath.join(home, "extra-tools", "bin"); - const convertedToolPath = NodePath.join(home, "converted-tools", "bin"); - NodeFS.mkdirSync(toolPath, { recursive: true }); - NodeFS.mkdirSync(extraToolPath, { recursive: true }); - NodeFS.mkdirSync(convertedToolPath, { recursive: true }); - const shell = NodePath.join(toolPath, "nushell"); - NodeFS.symlinkSync(process.env.T3_TEST_NU_PATH!, shell); - const config = NodePath.join(home, "config.nu"); - const envConfig = NodePath.join(home, "env.nu"); - NodeFS.writeFileSync(envConfig, ""); - NodeFS.writeFileSync( - config, - [ - `$env.PATH = [${JSON.stringify(toolPath)}, ${JSON.stringify(extraToolPath)}]`, - '$env.SSH_AUTH_SOCK = "/tmp/nu-agent.sock"', - '$env.__MISE_DIFF = " first\\nsecond "', - '$env.HOMEBREW_PREFIX = ""', - "$env.ENV_CONVERSIONS = {", - " PATH: {", - " from_string: {|value| $value | split row (char esep) },", - ' to_string: {|value| $value | str join (char esep) | str replace "nu tools" "converted-tools" }', - " },", - " __MISE_DIFF: { from_string: {|value| $value }, to_string: { str upcase } }", - "}", - 'print "startup noise"', - ].join("\n"), - ); - const env: NodeJS.ProcessEnv = { SHELL: shell, PATH: `/inherited/bin:${extraToolPath}` }; - yield* runShellEnvironment({ - env, - platform: "linux", - handler: (command) => { - if (command._tag !== "StandardCommand" || command.command !== shell) return ""; - const result = NodeChildProcess.spawnSync( - command.command, - ["--no-history", "--config", config, "--env-config", envConfig, ...command.args], - { - encoding: "utf8", - timeout: 5000, - env: { HOME: home, XDG_CONFIG_HOME: home, PATH: toolPath }, - }, - ); - if (result.error) throw result.error; - return { output: result.stdout, exitCode: result.status ?? 1 }; - }, - }); - - assert.deepEqual( - { PATH: env.PATH, __MISE_DIFF: env.__MISE_DIFF }, - { - PATH: `${convertedToolPath}:${extraToolPath}:/inherited/bin`, - __MISE_DIFF: " FIRST\nSECOND ", - }, - ); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/nu-agent.sock"); - assert.isUndefined(env.DISPLAY); - assert.isUndefined(env.HOMEBREW_PREFIX); - } finally { - NodeFS.rmSync(home, { recursive: true, force: true }); - } - }), - ); - it.effect("rejects PATH output from a failed shell and tries the next candidate", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; @@ -228,52 +141,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect.each(["darwin", "linux"] as const)( - "uses the account shell when SHELL is missing on %s", - (platform) => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; - const commands: string[] = []; - - yield* runShellEnvironment({ - env, - platform, - userShell: "/usr/local/bin/fish", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - commands.push(command.command); - return envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }); - }, - }); - - assert.deepEqual(commands, ["/usr/local/bin/fish"]); - assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); - }), - ); - - it.effect("tries the account shell before the platform fallback when SHELL has no PATH", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", PATH: "/usr/bin" }; - const commands: string[] = []; - - yield* runShellEnvironment({ - env, - platform: "linux", - userShell: "/usr/local/bin/fish", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - commands.push(command.command); - return command.command === "/usr/local/bin/fish" - ? envOutput({ PATH: "/home/test/.local/bin:/usr/bin" }) - : ""; - }, - }); - - assert.deepEqual(commands, ["/bin/missing", "/usr/local/bin/fish"]); - assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin"); - }), - ); - it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -303,72 +170,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("preserves inherited POSIX values when present", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - }), - }); - - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); - }), - ); - - it.effect("hydrates the locale from the login shell on macOS", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - LANG: "de_DE.UTF-8", - }), - }); - - assert.equal(env.LANG, "de_DE.UTF-8"); - }), - ); - - it.effect("preserves an inherited locale over the login shell on macOS", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - LANG: "en_US.UTF-8", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - handler: () => - envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", - LANG: "de_DE.UTF-8", - }), - }); - - assert.equal(env.LANG, "en_US.UTF-8"); - }), - ); - it.effect("does not mix login-shell locale categories into an inherited locale", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -411,47 +212,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("does not apply the locale fallback on linux", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }), - }); - - assert.equal(env.LANG, undefined); - }), - ); - - it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => - envOutput({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - T3CODE_TELEMETRY_ENABLED: "false", - }), - }); - - assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); - assert.equal(env.T3CODE_TELEMETRY_ENABLED, "false"); - }), - ); - it.effect("hydrates missing CA trust variables from the login shell", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -572,85 +332,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("waits at most 5 seconds in total when the PowerShell profile hangs", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; - const profileShells: string[] = []; - const started = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const baseClock = yield* Clock.Clock; - let wallTime = 0; - const clockWithBackwardWallJump: Clock.Clock = { - ...baseClock, - currentTimeMillisUnsafe: () => wallTime, - currentTimeMillis: Effect.sync(() => wallTime), - }; - - const install = yield* Effect.forkChild( - runShellEnvironment({ - env, - platform: "win32", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - if (command.args.includes("-NoProfile")) { - return envOutput({ PATH: "C:\\NoProfile\\Bin;C:\\Windows\\System32" }); - } - profileShells.push(command.command); - return Deferred.succeed(started, undefined).pipe( - Effect.as( - ChildProcessSpawner.makeHandle({ - ...makeProcess(""), - stdout: Stream.never, - all: Stream.never, - exitCode: Effect.never, - isRunning: Effect.succeed(true), - }), - ), - ); - }, - }).pipe( - Effect.tap(() => Deferred.succeed(completed, undefined)), - Effect.provide(Logger.layer([])), - Effect.provideService(Clock.Clock, clockWithBackwardWallJump), - ), - ); - yield* Deferred.await(started); - wallTime = -60_000; - yield* TestClock.adjust("5 seconds"); - assert.isTrue(yield* Deferred.isDone(completed), "finishes within the shared budget"); - assert.isFalse(yield* Fiber.join(install)); - assert.deepEqual(profileShells, ["pwsh.exe"]); - assert.equal(env.PATH, "C:\\NoProfile\\Bin;C:\\Windows\\System32"); - }), - ); - - it.effect("falls back to Windows PowerShell when pwsh.exe is not installed", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; - const missing = PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "pwsh.exe", - }); - - const prepared = yield* runShellEnvironment({ - env, - platform: "win32", - handler: (command) => { - if (command._tag !== "StandardCommand") return ""; - if (command.command === "pwsh.exe") return Effect.fail(missing); - return command.args.includes("-NoProfile") - ? envOutput({ PATH: "C:\\Windows\\System32" }) - : envOutput({ PATH: "C:\\Profile\\Node;C:\\Windows\\System32" }); - }, - }).pipe(Effect.provide(Logger.layer([]))); - - assert.isTrue(prepared); - assert.equal(env.PATH, "C:\\Profile\\Node;C:\\Windows\\System32"); - }), - ); - it.effect.each([false, true])( "leaves Windows preparation incomplete when loadProfile=%s fails", (loadProfile) => @@ -692,49 +373,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("drops earlier AppImage launches' mount dirs from the search paths on linux", () => - Effect.gen(function* () { - const current = "/var/tmp/.mount_cur"; - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - APPIMAGE: "/home/me/T3-Code.AppImage", - APPDIR: `${current}/`, - PATH: `${current}/usr/bin:/tmp/.mount_old/usr/bin:/usr/bin`, - LD_LIBRARY_PATH: `/tmp/.mount_old/usr/lib:${current}/usr/lib::/opt/lib`, - XDG_DATA_DIRS: "/tmp/.mount_old/usr/share:/var/tmp/.mount_cur2/usr/share", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/tmp/.mount_older/usr/bin:/home/me/.local/bin" }), - }); - - assert.equal(env.PATH, `/home/me/.local/bin:${current}/usr/bin:/usr/bin`); - assert.equal(env.LD_LIBRARY_PATH, `${current}/usr/lib::/opt/lib`); - assert.equal(env.XDG_DATA_DIRS, undefined); - }), - ); - - it.effect("keeps AppImage-looking search paths outside an AppImage launch", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/tmp/.mount_old/usr/bin:/usr/bin", - LD_LIBRARY_PATH: "/tmp/.mount_old/usr/lib", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => envOutput({ PATH: "/usr/bin" }), - }); - - assert.equal(env.PATH, "/usr/bin:/tmp/.mount_old/usr/bin"); - assert.equal(env.LD_LIBRARY_PATH, "/tmp/.mount_old/usr/lib"); - }), - ); - it.effect("prefers login-shell desktop session hints over inherited values on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -761,66 +399,4 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.XDG_SESSION_TYPE, "wayland"); }), ); - - it.effect("overrides stale dbus session addresses from the login shell", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", - }; - - yield* runShellEnvironment({ - env, - platform: "linux", - handler: () => - envOutput({ - PATH: "/usr/bin", - DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", - }), - }); - - assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); - }), - ); - - it.effect("logs command failures with safe probe context and the exact cause", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/bash", - PATH: "/usr/bin", - }; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "/bin/bash", - }); - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - - return runShellEnvironment({ - env, - platform: "linux", - handler: () => "", - failure: cause, - }).pipe( - Effect.andThen( - Effect.sync(() => { - const errors = messages - .flatMap((message) => (Array.isArray(message) ? message : [message])) - .filter(isDesktopShellEnvironmentCommandError); - assert.lengthOf(errors, 1); - assert.equal(errors[0]?.probe, "login-shell"); - assert.equal(errors[0]?.executable, "bash"); - assert.equal(errors[0]?.argumentCount, 2); - assert.notProperty(errors[0] ?? {}, "args"); - assert.equal(errors[0]?.cause, cause); - assert.notInclude(errors[0]?.message ?? "", cause.message); - }), - ), - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - ); - }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 51b4c1a90863..3fdec7932366 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,7 +3,6 @@ import { listLoginShellCandidates, setWindowsPath, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -119,37 +118,6 @@ const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); -const APPIMAGE_SEARCH_PATH_ENV_NAMES = ["PATH", "LD_LIBRARY_PATH", "XDG_DATA_DIRS"] as const; - -// The AppImage runtime mounts each launch at `$TMPDIR/.mount_` and prepends -// it to the search paths above. A launch started from a shell that inherited an -// earlier launch's env carries that mount too, and once it is unmounted every -// spawn that walks PATH (git first of all) fails with ENOTCONN. -const APPIMAGE_MOUNT_ROOT = /^(.*?\/\.mount_[^/]+)(?:\/|$)/u; - -const stripStaleAppImageMounts = (env: NodeJS.ProcessEnv): void => { - if (env.APPIMAGE === undefined && env.APPDIR === undefined) return; - const appDir = env.APPDIR === undefined ? undefined : normalizeRuntimeDir(env.APPDIR); - for (const name of APPIMAGE_SEARCH_PATH_ENV_NAMES) { - const value = env[name]; - if (value === undefined) continue; - // glibc's loader also splits LD_LIBRARY_PATH on `;`. - const segments = value.split(name === "LD_LIBRARY_PATH" ? /[:;]/u : ":"); - const kept = segments.filter((segment) => { - const mountRoot = APPIMAGE_MOUNT_ROOT.exec(segment)?.[1]; - return mountRoot === undefined || mountRoot === appDir; - }); - if (kept.length === segments.length) continue; - if (kept.length === 0) { - delete env[name]; - } else { - // An empty segment is the current dir to the loader and to PATH lookup, - // but a lone one joins to "", which reads as unset. - env[name] = kept.join(":") || ":"; - } - } -}; - const linuxRuntimeDirCandidates = ( env: NodeJS.ProcessEnv, uid: number | undefined, @@ -371,16 +339,12 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn captureWindowsEnvironmentCommand(names), ]; - const deadline = - (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(LOGIN_SHELL_TIMEOUT); for (const command of WINDOWS_SHELL_CANDIDATES) { - const remainingNanos = deadline - (yield* Clock.monotonicTimeNanos); - if (remainingNanos <= 0n) break; const output = yield* runCommandOutput({ probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, - timeout: Duration.nanos(remainingNanos), + timeout: LOGIN_SHELL_TIMEOUT, }); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { @@ -459,9 +423,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix if (Option.isSome(mergedPath)) { config.env.PATH = mergedPath.value; } - // The server, relaunch and updater all inherit process.env, so scrubbing - // here keeps earlier AppImage launches' mounts out of every one of them. - stripStaleAppImageMounts(config.env); if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } diff --git a/apps/desktop/src/snapShot/ActiveWindow.test.ts b/apps/desktop/src/snapShot/ActiveWindow.test.ts deleted file mode 100644 index c1849b3eed6e..000000000000 --- a/apps/desktop/src/snapShot/ActiveWindow.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; - -const { execFileMock, loadWindowsForegroundApiMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - loadWindowsForegroundApiMock: vi.fn(), -})); - -vi.mock("node:child_process", () => ({ execFile: execFileMock })); -vi.mock("../electron/WindowsForeground.ts", () => ({ - loadWindowsForegroundApi: loadWindowsForegroundApiMock, -})); - -import { activeWindow } from "./ActiveWindow.ts"; - -beforeEach(() => { - execFileMock.mockReset(); - loadWindowsForegroundApiMock.mockReset(); -}); - -function stubMacLookup(stdout: string) { - execFileMock.mockImplementation( - ( - _file: string, - _args: ReadonlyArray, - _options: unknown, - callback: (error: Error | null, stdout: string) => void, - ) => callback(null, stdout), - ); -} - -function macLookup( - windows: ReadonlyArray<{ - id: number; - title: string; - bounds: { x: number; y: number; width: number; height: number }; - alpha?: number; - }>, -) { - return JSON.stringify({ - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", - }, - windows: windows.map((window) => ({ ...window, alpha: window.alpha ?? 1 })), - }); -} - -it("parses the frontmost macOS window from the osascript lookup", async () => { - stubMacLookup( - macLookup([ - { - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - }, - ]) + "\n", - ); - - const window = await activeWindow("darwin"); - - assert.deepEqual(window, { - platform: "macos", - id: 42, - title: "main.ts", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { - name: "Editor", - processId: 123, - path: "/Applications/Editor.app", - bundleId: "com.example.editor", - }, - }); - const [file, args] = execFileMock.mock.calls[0]!; - assert.strictEqual(file, "/usr/bin/osascript"); - assert.deepEqual(args.slice(0, 3), ["-l", "JavaScript", "-e"]); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); -}); - -it("omits an empty macOS bundle identifier", async () => { - stubMacLookup( - JSON.stringify({ - owner: { name: "cli", processId: 9, path: "", bundleId: "" }, - windows: [ - { - id: 7, - title: "", - bounds: { x: 0, y: 0, width: 1, height: 1 }, - alpha: 1, - }, - ], - }), - ); - - const window = await activeWindow("darwin"); - - assert.deepEqual(window?.owner, { name: "cli", processId: 9, path: "" }); -}); - -it.each([ - { - name: "transparent and untitled helpers", - helpers: [ - { - id: 1, - title: "", - bounds: { x: -1, y: -44, width: 1_680, height: 44 }, - alpha: 0, - }, - { id: 2, title: "", bounds: { x: 0, y: 0, width: 1_680, height: 68 } }, - ], - content: { - id: 3, - title: "Document", - bounds: { x: 0, y: 0, width: 1_680, height: 1_050 }, - }, - }, - { - name: "Chrome strips stacked above the content window", - helpers: [ - { id: 75, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 41 } }, - { id: 74, title: "", bounds: { x: 0, y: 80, width: 1_800, height: 81 } }, - { id: 1691, title: "", bounds: { x: 0, y: 39, width: 1_800, height: 158 } }, - ], - content: { - id: 71, - title: "(10) Video - YouTube", - bounds: { x: 0, y: 161, width: 1_800, height: 1_008 }, - }, - }, - { - name: "a smaller front window's strips with a larger background window", - helpers: [ - { id: 75, title: "", bounds: { x: 100, y: 139, width: 1_200, height: 41 } }, - { id: 74, title: "", bounds: { x: 100, y: 180, width: 1_200, height: 81 } }, - ], - content: { - id: 71, - title: "Video - YouTube", - bounds: { x: 100, y: 261, width: 1_200, height: 700 }, - }, - background: [ - { id: 80, title: "Other document", bounds: { x: 0, y: 39, width: 1_800, height: 1_130 } }, - ], - }, -])("selects the content window behind $name", async ({ helpers, content, background = [] }) => { - stubMacLookup(macLookup([...helpers, content, ...background])); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, content.id); - assert.deepEqual(window?.bounds, content.bounds); -}); - -it.each([ - { name: "untitled dialog", title: "", bounds: { x: 300, y: 200, width: 400, height: 240 } }, - { name: "titled palette", title: "Palette", bounds: { x: 0, y: 0, width: 800, height: 50 } }, - { name: "offset untitled strip", title: "", bounds: { x: 100, y: 100, width: 800, height: 50 } }, - { name: "tall untitled window", title: "", bounds: { x: 0, y: 0, width: 800, height: 900 } }, -])("keeps a frontmost $name ahead of a larger document", async ({ title, bounds }) => { - stubMacLookup( - macLookup([ - { id: 1, title, bounds }, - { id: 2, title: "Document", bounds: { x: 0, y: 0, width: 800, height: 1_000 } }, - ]), - ); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, 1); - assert.deepEqual(window?.bounds, bounds); -}); - -it("keeps an untitled content window ahead of a much taller background window", async () => { - const bounds = { x: 0, y: 0, width: 800, height: 900 }; - stubMacLookup( - macLookup([ - { id: 1, title: "", bounds }, - { id: 2, title: "Background document", bounds: { x: 0, y: 0, width: 800, height: 4_000 } }, - ]), - ); - - const window = await activeWindow("darwin"); - - assert.strictEqual(window?.id, 1); - assert.deepEqual(window?.bounds, bounds); -}); - -it("resolves undefined when macOS has no frontmost window", async () => { - stubMacLookup("\n"); - - assert.isUndefined(await activeWindow("darwin")); -}); - -it("resolves undefined when macOS reports no visible windows", async () => { - stubMacLookup(macLookup([])); - - assert.isUndefined(await activeWindow("darwin")); -}); - -it("composes the Windows foreground window from Win32 calls", async () => { - const api = { - getForegroundWindow: vi.fn(() => 0x1_f123_4567n), - getWindowRect: vi.fn(() => ({ x: 10, y: 20, width: 800, height: 600 })), - getWindowThreadAndProcessId: vi.fn(() => ({ threadId: 5, processId: 123 })), - getProcessImagePath: vi.fn(() => "C:\\Program Files\\Editor\\editor.exe"), - getWindowText: vi.fn(() => "main.ts - Editor"), - }; - loadWindowsForegroundApiMock.mockResolvedValue(api); - - const window = await activeWindow("win32"); - - assert.deepEqual(window, { - platform: "windows", - id: 0x1_f123_4567, - title: "main.ts - Editor", - bounds: { x: 10, y: 20, width: 800, height: 600 }, - owner: { name: "editor.exe", processId: 123, path: "C:\\Program Files\\Editor\\editor.exe" }, - }); - assert.deepEqual(api.getWindowRect.mock.calls, [[0x1_f123_4567n]]); - assert.deepEqual(api.getProcessImagePath.mock.calls, [[123]]); - assert.lengthOf(execFileMock.mock.calls, 0); -}); - -it("resolves undefined when Windows reports no foreground window", async () => { - const api = { - getForegroundWindow: vi.fn(() => 0n), - getWindowRect: vi.fn(), - getWindowThreadAndProcessId: vi.fn(), - getProcessImagePath: vi.fn(), - getWindowText: vi.fn(), - }; - loadWindowsForegroundApiMock.mockResolvedValue(api); - - assert.isUndefined(await activeWindow("win32")); - assert.lengthOf(api.getWindowRect.mock.calls, 0); -}); - -it("resolves undefined on other platforms without querying the OS", async () => { - assert.isUndefined(await activeWindow("linux")); - assert.lengthOf(execFileMock.mock.calls, 0); - assert.lengthOf(loadWindowsForegroundApiMock.mock.calls, 0); -}); diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index 08c6cfa6ba04..f62669a39bf3 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -30,32 +30,23 @@ export type ActiveWindow = { const MAC_LOOKUP_TIMEOUT_MS = 5_000; -const MAC_HELPER_STRIP_MAX_THICKNESS_RATIO = 0.25; - -const MacActiveWindowLookup = Schema.Struct({ +const MacActiveWindow = Schema.Struct({ + id: Schema.Number, + title: Schema.String, + bounds: Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, + }), owner: Schema.Struct({ name: Schema.String, processId: Schema.Number, path: Schema.String, bundleId: Schema.String, }), - windows: Schema.Array( - Schema.Struct({ - id: Schema.Number, - title: Schema.String, - bounds: Schema.Struct({ - x: Schema.Number, - y: Schema.Number, - width: Schema.Number, - height: Schema.Number, - }), - alpha: Schema.Number, - }), - ), }); -const decodeMacActiveWindowLookup = Schema.decodeUnknownSync( - Schema.fromJsonString(MacActiveWindowLookup), -); +const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); @@ -64,38 +55,30 @@ function run() { const app = $.NSWorkspace.sharedWorkspace.frontmostApplication; if (app.isNil()) return ""; const pid = app.processIdentifier; - let ownerName = String(app.localizedName.js || ""); const list = $.CGWindowListCopyWindowInfo( $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID, ); $.CFMakeCollectable(list); const count = $.CFArrayGetCount(list); - const windows = []; for (let i = 0; i < count; i++) { const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)); if (w.objectForKey("kCGWindowOwnerPID").js !== pid) continue; if (w.objectForKey("kCGWindowLayer").js !== 0) continue; - if (!ownerName) { - ownerName = String(ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""); - } const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds")); - windows.push({ + return JSON.stringify({ id: w.objectForKey("kCGWindowNumber").js, title: String(ObjC.unwrap(w.objectForKey("kCGWindowName")) || ""), bounds: { x: b.X, y: b.Y, width: b.Width, height: b.Height }, - alpha: w.objectForKey("kCGWindowAlpha").js, + owner: { + name: String(app.localizedName.js || ObjC.unwrap(w.objectForKey("kCGWindowOwnerName")) || ""), + processId: pid, + path: String(app.bundleURL.path.js || ""), + bundleId: String(app.bundleIdentifier.js || ""), + }, }); } - return JSON.stringify({ - owner: { - name: ownerName, - processId: pid, - path: String(app.bundleURL.path.js || ""), - bundleId: String(app.bundleIdentifier.js || ""), - }, - windows, - }); + return ""; }`; function runMacLookup(): Promise { @@ -103,7 +86,7 @@ function runMacLookup(): Promise { NodeChildProcess.execFile( "/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_LOOKUP_SCRIPT], - { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, + { timeout: MAC_LOOKUP_TIMEOUT_MS, maxBuffer: 64 * 1024 }, (error, stdout) => { if (error) reject(error); else resolve(stdout); @@ -115,39 +98,17 @@ function runMacLookup(): Promise { async function macActiveWindow(): Promise { const output = (await runMacLookup()).trim(); if (!output) return undefined; - const lookup = decodeMacActiveWindowLookup(output); - const visible = lookup.windows.filter( - (window) => window.alpha > 0 && window.bounds.width > 0 && window.bounds.height > 0, - ); - // Helpers belong to the content behind them, which may not be the app's largest window. - const window = visible.find((candidate, index) => { - if (candidate.title.trim() !== "") return true; - const bounds = candidate.bounds; - return !visible.some((reference, referenceIndex) => { - if (referenceIndex <= index) return false; - return ( - (bounds.x === reference.bounds.x && - bounds.width === reference.bounds.width && - bounds.height <= bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && - bounds.height <= reference.bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) || - (bounds.y === reference.bounds.y && - bounds.height === reference.bounds.height && - bounds.width <= bounds.height * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO && - bounds.width <= reference.bounds.width * MAC_HELPER_STRIP_MAX_THICKNESS_RATIO) - ); - }); - }); - if (!window) return undefined; + const window = decodeMacActiveWindow(output); return { platform: "macos", id: window.id, title: window.title, bounds: window.bounds, owner: { - name: lookup.owner.name, - processId: lookup.owner.processId, - path: lookup.owner.path, - ...(lookup.owner.bundleId ? { bundleId: lookup.owner.bundleId } : {}), + name: window.owner.name, + processId: window.owner.processId, + path: window.owner.path, + ...(window.owner.bundleId ? { bundleId: window.owner.bundleId } : {}), }, }; } diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index ea6e176b8204..e2a3b913e648 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -2,10 +2,8 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeOS from "node:os"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeUtil from "node:util"; import { afterEach, beforeEach, expect, it as test, vi } from "vite-plus/test"; -import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts"; +import { CaptureShortcutConfig } from "./CaptureShortcutConfig.ts"; import { captureConfigBinding } from "./captureConfigEdit.ts"; // oxlint-disable-next-line t3code/no-global-process-runtime -- Test collection checks the host before starting these Linux-native filesystem tests. @@ -50,12 +48,6 @@ it("reads without writing, then applies only the approved diff with a backup", a expect(tools.validateNiri).toHaveBeenCalledOnce(); await expect(setup.apply(preview.id, "niri")).rejects.toThrow("expired"); }); -it("refuses apply without a preview or for the wrong desktop", async () => { - await expect(setup.apply("invented", "niri")).rejects.toThrow("expired"); - const preview = await setup.preview(target(), install); - await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("expired"); - expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); -}); it("rechecks Hyprland conflicts before writing", async () => { const hypr = { ...target(), @@ -80,11 +72,6 @@ it("notices an optional include created while the diff is open", async () => { await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); }); -it("invalidates an earlier proposal when another config is opened", async () => { - const first = await setup.preview(target(), install); - await setup.preview(target(), { ...install, shortcut: "Ctrl+Alt+Y" }); - await expect(setup.apply(first.id, "niri")).rejects.toThrow("expired"); -}); it("does not overwrite edits made after preview", async () => { const preview = await setup.preview(target(), install); await NodeFSP.appendFile(path, "// edited elsewhere\n"); @@ -243,52 +230,6 @@ it("detects conflicts in included Niri configs without changing either file", as await expect(setup.preview(target(), install)).rejects.toThrow("already used"); expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "keys.kdl"]); }); -it("detects included config changes before applying", async () => { - await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); - const included = NodePath.join(directory, "keys.kdl"); - await NodeFSP.writeFile(included, "binds { Ctrl+Alt+Q { quit; } }"); - const preview = await setup.preview(target(), install); - await NodeFSP.writeFile(included, "binds { Ctrl+Shift+2 { quit; } }"); - await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); -}); -it("rejects a conflict from another Hyprland config or dynamic Lua bind", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "bindings.lua"), - }; - await NodeFSP.writeFile(hypr.path, "-- user bindings\n"); - tools.hyprlandBindings.mockResolvedValue([ - { modmask: 5, key: "2", dispatcher: "__lua", arg: "92" }, - ]); - await expect(setup.preview(hypr, install)).rejects.toThrow("already used"); - expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe("-- user bindings\n"); -}); -it("reloads Hyprland after apply, but not after read", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "hyprland.conf"), - }; - await NodeFSP.writeFile(hypr.path, "# preferences\n"); - const preview = await setup.preview(hypr, install); - expect(tools.reloadHyprland).not.toHaveBeenCalled(); - await setup.apply(preview.id, "hyprland"); - expect(tools.reloadHyprland).toHaveBeenCalledOnce(); -}); -it("reports reload failure separately from a successfully saved config", async () => { - const hypr = { - ...target(), - desktop: "hyprland" as const, - path: NodePath.join(directory, "hyprland.conf"), - }; - await NodeFSP.writeFile(hypr.path, "# preferences\n"); - const preview = await setup.preview(hypr, install); - tools.reloadHyprland.mockRejectedValue(new Error("Compositor stopped")); - const result = await setup.apply(preview.id, "hyprland"); - expect(result.warning).toContain("Config saved"); - expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(preview.after); -}); it("does not edit Omarchy's shipped defaults", async () => { const defaults = NodePath.join(directory, "omarchy", "default", "hypr"); await NodeFSP.mkdir(defaults, { recursive: true }); @@ -298,46 +239,3 @@ it("does not edit Omarchy's shipped defaults", async () => { setup.preview({ ...target(), desktop: "hyprland", path: file }, install), ).rejects.toThrow("not system or Omarchy"); }); -it("honors NIRI_CONFIG and XDG_CONFIG_HOME", () => { - expect( - niriCaptureConfigPath("/home/test", { - NIRI_CONFIG: "/custom/test.kdl", - XDG_CONFIG_HOME: "/config", - }), - ).toBe("/custom/test.kdl"); - expect(niriCaptureConfigPath("/home/test", { XDG_CONFIG_HOME: "/config" })).toBe( - "/config/niri/config.kdl", - ); - expect(niriCaptureConfigPath("/home/test", {})).toBe("/home/test/.config/niri/config.kdl"); -}); -it("preserves a UTF-8 BOM through preview and apply", async () => { - await NodeFSP.writeFile(path, "\uFEFFbinds {}\r\n"); - const preview = await setup.preview(target(), install); - await setup.apply(preview.id, "niri"); - expect((await NodeFSP.readFile(path)).subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf])); -}); -it("the installed Niri parser accepts the generated edit and its relative includes", async (context) => { - try { - await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["--version"]); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - context.skip(); - return; - } - throw error; - } - await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); - await NodeFSP.writeFile( - NodePath.join(directory, "keys.kdl"), - "binds {\n Ctrl+Alt+Q { quit; }\n}\n", - ); - const checked = new CaptureShortcutConfig(NodeOS.homedir(), { - ...tools, - validateNiri: async (file) => { - await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["validate", "--config", file]); - }, - }); - const preview = await checked.preview(target(), install); - await checked.apply(preview.id, "niri"); - expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.after); -}); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 152de5f9478b..39a7dddf5de7 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -11,7 +11,6 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -476,12 +475,7 @@ const decodePendingMetadata = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopPendingSnapShot), ); -import { - SnapShotTransition, - snapShotAnimationDurationMs, - snapShotAnimationDisplayBounds, - snapShotAnimationOverlayBounds, -} from "./SnapShotTransition.ts"; +import { SnapShotTransition, snapShotAnimationDisplayBounds } from "./SnapShotTransition.ts"; const layerTest = ( platform: NodeJS.Platform, fileSystemOverrides: Parameters[0] = {}, @@ -760,116 +754,6 @@ function concurrentCaptureFixture(platform: NodeJS.Platform, animations: boolean }; } -it.effect("rejects desktop config changes outside Niri or Hyprland", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const previewError = yield* service - .previewConfig({ operation: "install", chooseFile: false }) - .pipe(Effect.flip); - const applyError = yield* service.applyConfig("unapproved-preview").pipe(Effect.flip); - assert.equal(previewError.action, "preview-config"); - assert.equal(applyError.action, "apply-config"); - for (const error of [previewError, applyError]) { - assert.equal(error.reason, "unsupported-session"); - assert.equal(error.message, "Config setup requires a Niri or Hyprland session."); - } - }), - ).pipe(Effect.provide(layerTest("win32"))), -); - -it.effect("reads and acknowledges queued captures through Effect services", () => { - const captureId = "12345678-1234-1234-1234-123456789abc"; - const captureDirectory = "/state/snap-shots"; - const metadataPath = captureDirectory + "/" + captureId + ".json"; - const imagePath = captureDirectory + "/" + captureId + ".png"; - const removed: Array = []; - const metadata = JSON.stringify({ - id: captureId, - name: "window.png", - mimeType: "image/png", - sizeBytes: 3, - source: { - kind: "snap-shot", - capturedAt: "2026-08-24T11:00:00.000Z", - appName: "Editor", - windowTitle: "main.ts", - }, - }); - const layer = layerTest("linux", { - readDirectory: () => Effect.succeed([captureId + ".json", "invalid.json"]), - readFileString: (filePath) => Effect.succeed(filePath === metadataPath ? metadata : "invalid"), - readFile: () => Effect.succeed(new Uint8Array([1, 2, 3])), - remove: (filePath) => - Effect.sync(() => { - removed.push(filePath); - }), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const pending = yield* service.listPending; - assert.deepEqual( - pending.map((capture) => capture.id), - [captureId], - ); - - const capture = yield* service.read(captureId); - assert.strictEqual(capture.dataUrl, "data:image/png;base64,AQID"); - - yield* service.acknowledge(captureId); - assert.deepEqual(removed.sort(), [imagePath, metadataPath].sort()); - }), - ).pipe(Effect.provide(layer)); -}); - -it.effect("captures the active Windows window without enumerating desktop sources", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "windows", - id: 42, - title: "Untitled - Paint", - owner: { name: "Paint.exe", processId: 123, path: "C:\\Windows\\System32\\mspaint.exe" }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - getSourcesMock.mockReset(); - getFileIconMock.mockReset().mockResolvedValue(fakeIcon("file")); - const writtenFiles: Array<[string, Uint8Array]> = []; - let metadata = ""; - const layer = layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (path, bytes) => - Effect.sync(() => { - writtenFiles.push([path, bytes]); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.deepEqual(regionCaptureMock.mock.calls, [[active.bounds]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.deepEqual(getFileIconMock.mock.calls, [[active.owner.path, { size: "normal" }]]); - assert.deepEqual(writtenFiles[0]?.[1], png); - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.appName, "Paint"); - assert.match(saved.source.appIconDataUrl ?? "", /base64,file:/); - }), - ).pipe(Effect.provide(layer)); -}); - it.effect.each(["darwin", "win32"] as const)( "drops shortcut bursts during flight and accepts a fresh capture after landing on %s", (platform) => { @@ -987,174 +871,6 @@ it.effect.each([ }, ); -it.effect.each(["win32", "darwin", "linux"] as const)( - "captures the foreground window in place from the shortcut on %s", - (platform) => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - mediaAccessStatusMock.mockReturnValue("granted"); - animationSettingsMock.mockReturnValue({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - const bounds = { x: 10, y: 20, width: 800, height: 600 }; - const t3 = { - id: 42, - title: "T3 Code", - appIdentifier: "com.t3tools.T3Code.desktop", - owner: { name: "T3 Code", processId: 123 }, - bounds, - png: Buffer.from([1, 2, 3]), - }; - focusedWindowMock.mockReturnValue({ - getBounds: () => bounds, - getTitle: () => t3.title, - isDestroyed: () => false, - isMinimized: () => false, - isVisible: () => true, - show: vi.fn(), - restore: vi.fn(), - }); - const images: Uint8Array[] = []; - activeWindowMock.mockReset().mockResolvedValue({ - ...t3, - platform: platform === "darwin" ? "macos" : "windows", - }); - regionCaptureMock.mockReset().mockResolvedValue({ - width: bounds.width, - height: bounds.height, - png: t3.png, - }); - macCaptureMock.mockReset().mockImplementation(async () => { - images.push(t3.png); - return { source: { name: t3.title }, png: t3.png }; - }); - const activate = vi.fn<(title: string) => Promise>().mockResolvedValue(undefined); - linuxCaptureMock.mockResolvedValueOnce({ - png: t3.png, - window: { - title: t3.title, - appName: t3.owner.name, - appIdentifier: t3.appIdentifier, - processId: t3.owner.processId, - bounds, - }, - feedback: { - animationStarted: true, - activate, - animateTo: async () => undefined, - complete: async () => undefined, - close: () => undefined, - }, - }); - const readAccessibility = accessibilityProcessReadMock.getMockImplementation()!; - accessibilityProcessReadMock.mockImplementation(({ active }) => ({ - started: Promise.resolve(), - result: Promise.resolve({ accessibleText: `Window from process ${active.owner.processId}` }), - })); - let metadata = ""; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure( - enabledSettings({ - snapShotFlash: false, - snapShotShortcut: { - key: "2", - ctrlKey: true, - shiftKey: true, - altKey: false, - metaKey: false, - modKey: false, - }, - }), - ); - const trigger = - platform === "linux" - ? portalShortcutInstances.at(-1)!.onCapture - : registerShortcutMock.mock.calls.at(-1)![1]; - yield* Effect.promise(trigger); - - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.windowTitle, t3.title); - assert.equal(saved.source.appName, t3.owner.name); - assert.equal(saved.source.accessibleText, `Window from process ${t3.owner.processId}`); - assert.deepEqual(images, [t3.png]); - assert.equal(prepareCaptureRevealMock.mock.calls.length, platform === "win32" ? 1 : 0); - if (platform === "linux") { - assert.equal(saved.source.appIdentifier, t3.appIdentifier); - assert.deepEqual(activate.mock.calls, [[t3.title]]); - } - }), - ).pipe( - Effect.provide( - layerTest(platform, { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (_, bytes) => - Effect.sync(() => { - images.push(bytes); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }), - ), - Effect.ensuring( - Effect.sync(() => { - focusedWindowMock.mockReset(); - linuxCaptureMock.mockReset().mockResolvedValue(undefined); - accessibilityProcessReadMock.mockReset().mockImplementation(readAccessibility); - mediaAccessStatusMock.mockReturnValue("not-determined"); - animationSettingsMock.mockReturnValue({ - prefersReducedMotion: true, - shouldRenderRichAnimation: false, - }); - vi.unstubAllEnvs(); - }), - ), - ); - }, -); - -it.effect.each(["win32", "darwin", "linux"] as const)( - "ignores shortcut repeats for 200 ms without delaying the first capture on %s", - (platform) => { - const fixture = concurrentCaptureFixture(platform, false); - fixture.releaseAll(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(fixture.settings); - yield* service.setShortcutSuppressed(true); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 0); - yield* service.setShortcutSuppressed(false); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 1); - assert.lengthOf(fixture.readyIds, 1); - - yield* Effect.promise(fixture.trigger); - yield* TestClock.adjust("199 millis"); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 1); - assert.lengthOf(fixture.readyIds, 1); - - yield* TestClock.adjust("1 millis"); - yield* Effect.promise(fixture.trigger); - assert.equal(fixture.state.snapshots, 2); - assert.lengthOf(fixture.readyIds, 2); - - yield* service.capture; - assert.equal(fixture.state.snapshots, 3); - assert.lengthOf(fixture.readyIds, 3); - }), - ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset))); - }, -); - it.effect.each([ { platform: "win32", animations: true }, { platform: "darwin", animations: false }, @@ -1268,41 +984,6 @@ it.effect.each(["succeeds", "fails"] as const)( }, ); -it.effect("keeps newer native feedback when older accessibility persistence fails", () => { - const fixture = concurrentCaptureFixture("linux", true); - fixture.state.failFirstPersistence = true; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(fixture.settings); - fixture.first.pixels.resolve(); - const first = yield* Effect.promise(fixture.trigger).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.promise(() => fixture.first.handoff.promise); - yield* TestClock.adjust("200 millis"); - fixture.second.pixels.resolve(); - const second = yield* Effect.promise(fixture.trigger).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.promise(() => fixture.second.handoff.promise); - fixture.first.context.resolve({ accessibleText: "Discord accessibility" }); - yield* Fiber.join(first); - - assert.lengthOf(fixture.readyIds, 0); - assert.lengthOf(fixture.second.feedback.close.mock.calls, 0); - assert.lengthOf(fixture.second.feedback.complete.mock.calls, 0); - fixture.second.context.resolve({ accessibleText: "Explorer accessibility" }); - yield* Fiber.join(second); - assert.lengthOf(fixture.readyIds, 1); - const newer = yield* service.read(fixture.readyIds[0]!); - assert.equal(newer.source.windowTitle, fixture.second.title); - yield* service.acknowledge(fixture.readyIds[0]!); - assert.lengthOf(fixture.second.feedback.complete.mock.calls, 1); - }).pipe(Effect.ensuring(Effect.sync(fixture.releaseAll))), - ).pipe(Effect.provide(fixture.layer), Effect.ensuring(Effect.sync(fixture.reset))); -}); - it.effect("matches Windows accessibility windows on a scaled display", () => { const png = Buffer.from([1, 2, 3]); const active = { @@ -1368,94 +1049,6 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect("skips accessibility capture when the setting is disabled", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "windows", - id: 42, - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - accessibilityProcessWarmMock.mockClear(); - accessibilityProcessReadMock.mockClear(); - accessibilityByPidMock.mockClear(); - const layer = layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings({ snapShotIncludeAccessibility: false })); - yield* service.capture; - - assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 0); - assert.lengthOf(accessibilityProcessReadMock.mock.calls, 0); - assert.lengthOf(accessibilityByPidMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layer)); -}); - -it.effect("keeps an accessibility helper warm only while capture data is enabled", () => { - accessibilityProcessWarmMock.mockClear(); - accessibilityProcessCoolMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: true, - }); - - assert.lengthOf(accessibilityProcessWarmMock.mock.calls, 1); - assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 0); - - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: false, - snapShotIncludeAccessibility: true, - }); - - assert.lengthOf(accessibilityProcessCoolMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("rejects X11 capture without registering shortcuts or loading capture backends", () => { - vi.stubEnv("XDG_SESSION_TYPE", "x11"); - vi.stubEnv("WAYLAND_DISPLAY", ""); - linuxCaptureMock.mockClear(); - activeWindowMock.mockClear(); - registerShortcutMock.mockClear(); - shortcutProcesses.length = 0; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); - const state = yield* service.state; - assert.equal(state.mode, "unavailable"); - assert.include(state.message, "Wayland"); - const result = yield* Effect.flip(service.capture); - assert.equal(result.operation, "unsupported"); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.lengthOf(shortcutProcesses, 0); - assert.lengthOf(linuxCaptureMock.mock.calls, 0); - assert.lengthOf(activeWindowMock.mock.calls, 0); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( "persists %s text without inventing AT-SPI screen coordinates", (backend) => { @@ -1553,613 +1146,28 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( }, ); -it.effect("persists a window capture from a portal activation without a setup test", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - linuxCaptureMock.mockClear().mockResolvedValueOnce({ - png: Buffer.from([1, 2, 3]), - window: { - title: "Shortcut capture", - appName: "Text Editor", - appIdentifier: "org.kde.kwrite", - processId: 123, - bounds: { x: 0, y: 0, width: 700, height: 520 }, - }, - }); - let metadata = ""; - const writes: Uint8Array[] = []; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: false, - snapShotShortcut: { - key: "2", - ctrlKey: true, - shiftKey: true, - altKey: false, - metaKey: false, - modKey: false, - }, - }); - yield* Effect.promise(portalShortcutInstances[0]!.onCapture); - assert.lengthOf(linuxCaptureMock.mock.calls, 1); - assert.deepEqual(writes, [Buffer.from([1, 2, 3])]); - const saved = yield* decodePendingMetadata(metadata); - assert.equal(saved.source.windowTitle, "Shortcut capture"); - assert.equal(saved.source.appIdentifier, "org.kde.kwrite"); - const state = yield* service.state; - assert.isTrue(state.shortcutVerified); - assert.isNull(state.message); - const portal = portalShortcutInstances[0]!; - portal.state = { - shortcutRegistered: false, - shortcutPending: false, - shortcutMessage: "Permission revoked", - }; - portal.onStateChanged(); - const revoked = yield* service.state; - assert.isFalse(revoked.shortcutVerified); - assert.isFalse(revoked.shortcutRegistered); - }), - ).pipe( - Effect.provide( - layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: (_, bytes) => - Effect.sync(() => { - writes.push(bytes); - }), - writeFileString: (_, text) => - Effect.sync(() => { - metadata = text; - }), - }), +it("covers straddling captures and intervening monitors without spanning unrelated displays", () => { + const displays = [ + { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, + { bounds: { x: 0, y: 0, width: 1_440, height: 900 } }, + { bounds: { x: 1_440, y: 0, width: 1_280, height: 720 } }, + { bounds: { x: 0, y: -1_080, width: 1_920, height: 1_080 } }, + ]; + const source = { x: -200, y: 100, width: 800, height: 600 }; + assert.deepEqual( + snapShotAnimationDisplayBounds(displays, source, source), + displays.slice(0, 2).map((display) => display.bounds), + ); + assert.deepEqual( + snapShotAnimationDisplayBounds( + displays, + { x: -1_800, y: 100, width: 800, height: 600 }, + { x: 1_600, y: 200, width: 200, height: 112 }, ), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + displays.slice(0, 3).map((display) => display.bounds), ); }); -it.effect("does not fall back to the picker when an automatic Wayland capture fails", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - linuxCaptureMock.mockRejectedValueOnce(new Error("Capture denied")); - getSourcesMock.mockClear(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - const failure = yield* Effect.flip(service.capture); - assert.equal(failure.operation, "capture"); - assert.lengthOf(getSourcesMock.mock.calls, 0); - }), - ).pipe( - Effect.provide( - layerTest("linux", { - makeDirectory: () => Effect.void, - remove: () => Effect.void, - }), - ), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect( - "logs a GNOME activation failure and completes the shell flight before acknowledging the image", - () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const order: string[] = []; - const activationFailure = new Error("Activation failed"); - const logs: Array = []; - const logger = Logger.make(({ message }) => logs.push(message)); - const feedback = { - animationStarted: true, - activate: async () => { - order.push("activate"); - throw activationFailure; - }, - animateTo: async () => { - order.push("land"); - }, - complete: async () => { - order.push("complete"); - }, - close: () => { - order.push("close"); - }, - }; - linuxCaptureMock.mockImplementationOnce(async () => { - order.push("snapshot"); - return { png: Buffer.from([1, 2, 3]), feedback }; - }); - focusedWindowMock.mockReturnValue(undefined); - const destination = { - getBounds: () => ({ x: 0, y: 0, width: 1000, height: 800 }), - getTitle: () => "T3 Code", - isDestroyed: () => false, - isVisible: () => true, - isMinimized: () => false, - }; - allWindowsMock.mockReturnValue([destination]); - let saved = ""; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - assert.deepEqual(order, ["snapshot", "activate"]); - const warning = logs.find( - (message) => - Array.isArray(message) && - message[0] === "The compositor could not activate T3 Code after the snapshot", - ); - assert.strictEqual(Array.isArray(warning) ? warning[1] : undefined, activationFailure); - const pending = yield* decodePendingMetadata(saved); - yield* service.setAnimationDestination(pending.id, { - frame: { x: 0, y: 0, width: 10, height: 10 }, - relativeFrame: { x: 0.1, y: 0.8, width: 0.2, height: 0.1 }, - backgroundColor: "white", - borderColor: "black", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - yield* service.acknowledge(pending.id); - assert.deepEqual(order, ["snapshot", "activate", "land", "complete", "delete", "delete"]); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: (_, value) => - Effect.sync(() => { - saved = value; - }), - remove: () => - Effect.sync(() => { - order.push("delete"); - }), - }), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - Effect.ensuring( - Effect.sync(() => { - allWindowsMock.mockReturnValue([]); - vi.unstubAllEnvs(); - }), - ), - ); - }, -); - -it.effect("does not read unverified accessibility context for a Wayland portal capture", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const png = Buffer.from([1, 2, 3]); - accessibilityForegroundMock.mockReset(); - getSourcesMock.mockReset().mockResolvedValue([ - { - id: "window:42:0", - name: "Untitled", - thumbnail: { isEmpty: () => false, toPNG: () => png }, - }, - ]); - const layer = layerTest("linux", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.lengthOf(accessibilityForegroundMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); -}); - -it.effect("uses display-local macOS capture surfaces across the source and main displays", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "macos", - id: 42, - title: "Terminal", - owner: { name: "Terminal", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - macCaptureMock.mockReset().mockResolvedValue({ - source: { name: "Terminal" }, - png, - }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - focusedWindowMock.mockReturnValue({ - getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }), - isDestroyed: () => false, - }); - flashWindows.length = 0; - const layer = layerTest("darwin", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - const transitionWindows = flashWindows.filter((window) => window.kind === "browser"); - assert.deepEqual( - transitionWindows.map((window) => window.bounds), - [ - { x: 0, y: -200, width: 1_440, height: 900 }, - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - ], - ); - for (const transitionWindow of transitionWindows) { - assert.deepEqual(transitionWindow.alwaysOnTopCalls, [[true, "pop-up-menu"]]); - } - }), - ).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => focusedWindowMock.mockReset()))); -}); - -it.effect.each(["ready", "failed"] as const)( - "shows the Windows transition only once its snapshot decode is %s", - (outcome) => { - const png = Buffer.from([1, 2, 3]); - activeWindowMock.mockReset().mockResolvedValue({ - platform: "windows", - id: 42, - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - }); - regionCaptureMock.mockReset().mockResolvedValue({ width: 800, height: 600, png }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - const decoding = Promise.withResolvers(); - const decoded = Promise.withResolvers(); - transitionSnapshotMock.mockImplementation(() => { - decoding.resolve(); - return decoded.promise; - }); - focusedWindowMock.mockReturnValue({ - getBounds: () => ({ x: 100, y: 50, width: 1_200, height: 800 }), - isDestroyed: () => false, - }); - transitionShowMock.mockClear(); - flashWindows.length = 0; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure( - enabledSettings({ snapShotIncludeAccessibility: false, snapShotFlash: false }), - ); - const capture = yield* service.capture.pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => decoding.promise); - assert.lengthOf(transitionShowMock.mock.calls, 0); - - if (outcome === "ready") decoded.resolve(); - else decoded.reject(new Error("Snapshot decode failed")); - yield* Fiber.join(capture); - - if (outcome === "ready") { - assert.isNotEmpty(transitionShowMock.mock.calls); - } else { - assert.lengthOf(transitionShowMock.mock.calls, 0); - assert.isTrue(flashWindows.every((window) => window.destroyed)); - } - }), - ).pipe( - Effect.provide( - layerTest("win32", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFile: () => Effect.void, - writeFileString: () => Effect.void, - }), - ), - Effect.ensuring( - Effect.sync(() => { - decoded.resolve(); - focusedWindowMock.mockReset(); - }), - ), - ); - }, -); - -it.effect("uses the unfocused main window for a macOS cross-display transition", () => { - const png = Buffer.from([1, 2, 3]); - const active = { - platform: "macos", - id: 42, - title: "Terminal", - owner: { name: "Terminal", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, - } as const; - activeWindowMock.mockReset().mockResolvedValue(active); - accessibilityByPidMock.mockReset().mockResolvedValue({ children: async () => [] }); - macCaptureMock.mockReset().mockResolvedValue({ source: { name: "Terminal" }, png }); - animationSettingsMock.mockReturnValueOnce({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }); - focusedWindowMock.mockReturnValue(undefined); - allWindowsMock.mockReturnValue([ - { - getBounds: () => ({ x: -1_600, y: 100, width: 1_200, height: 800 }), - isDestroyed: () => false, - }, - ]); - flashWindows.length = 0; - const layer = layerTest("darwin", { - makeDirectory: () => Effect.void, - rename: () => Effect.void, - writeFileString: () => Effect.void, - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(enabledSettings()); - yield* service.capture; - - assert.deepEqual( - flashWindows.filter((window) => window.kind === "browser").map((window) => window.bounds), - [ - { x: 0, y: -200, width: 1_440, height: 900 }, - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - ], - ); - }), - ).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - focusedWindowMock.mockReset(); - allWindowsMock.mockReset(); - }), - ), - ); -}); - -function fakeIcon(label: string, empty = false): Electron.NativeImage { - return { - isEmpty: () => empty, - resize: ({ width, height, quality }) => ({ - toDataURL: (options) => - "data:image/png;base64," + - label + - ":" + - width + - "x" + - height + - ":" + - quality + - "@" + - options?.scaleFactor, - }), - } as Electron.NativeImage; -} - -it.each([ - ["OS app", fakeIcon("file"), fakeIcon("captured"), "file"], - ["captured app", fakeIcon("file", true), fakeIcon("captured"), "captured"], -])("exports the %s icon at high density", (_source, fileIcon, capturedIcon, expectedLabel) => { - const dataUrl = DesktopSnapShot.snapShotIconDataUrl(fileIcon, capturedIcon); - - assert.strictEqual(dataUrl, "data:image/png;base64," + expectedLabel + ":64x64:best@2"); -}); - -const activeEditor = { - owner: { path: "/Applications/Editor.app" }, -} as Parameters[1]; - -it.each([ - [ - "a failed thumbnail on darwin", - "darwin" as const, - () => thumbnailFromPathMock.mockRejectedValue(new Error("no thumbnail")), - ], - ["other platforms", "win32" as const, () => {}], -])( - "requests the file icon at a size supported on macOS after %s", - async (_case, platform, arrange) => { - getFileIconMock.mockReset(); - thumbnailFromPathMock.mockReset(); - arrange(); - getFileIconMock.mockResolvedValue(fakeIcon("file")); - - const dataUrl = await DesktopSnapShot.iconDataUrl( - { appIcon: fakeIcon("captured") }, - activeEditor, - platform, - ); - - assert.deepEqual(getFileIconMock.mock.calls, [ - ["/Applications/Editor.app", { size: "normal" }], - ]); - assert.strictEqual(dataUrl, "data:image/png;base64,file:64x64:best@2"); - }, -); - -it("uses the primary display for portal flash feedback", () => { - assert.deepEqual(DesktopSnapShot.snapShotFlashBounds(undefined, "linux"), { - x: 0, - y: 0, - width: 1_440, - height: 900, - }); -}); - -it("uses the operating system animation policy", () => { - assert.isTrue( - DesktopSnapShot.shouldAnimateSnapShot({ - prefersReducedMotion: false, - shouldRenderRichAnimation: true, - }), - ); - assert.isFalse( - DesktopSnapShot.shouldAnimateSnapShot({ - prefersReducedMotion: true, - shouldRenderRichAnimation: true, - }), - ); -}); - -it("scales transition timing with travel distance", () => { - const source = { x: 0, y: 0, width: 200, height: 100 }; - assert.strictEqual(snapShotAnimationDurationMs(source, source), 280); - const near = snapShotAnimationDurationMs(source, { - x: 100, - y: 0, - width: 200, - height: 100, - }); - const far = snapShotAnimationDurationMs(source, { - x: 1_000, - y: 0, - width: 200, - height: 100, - }); - - assert.isAtLeast(near, 280); - assert.isBelow(far, 570); - assert.isAbove(far, near); -}); - -it("bounds the transition surface to its displays", () => { - assert.deepEqual( - snapShotAnimationOverlayBounds([ - { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, - { bounds: { x: 0, y: -200, width: 1_440, height: 900 } }, - ]), - { x: -1_920, y: -200, width: 3_360, height: 1_280 }, - ); -}); - -it("covers straddling captures and intervening monitors without spanning unrelated displays", () => { - const displays = [ - { bounds: { x: -1_920, y: 0, width: 1_920, height: 1_080 } }, - { bounds: { x: 0, y: 0, width: 1_440, height: 900 } }, - { bounds: { x: 1_440, y: 0, width: 1_280, height: 720 } }, - { bounds: { x: 0, y: -1_080, width: 1_920, height: 1_080 } }, - ]; - const source = { x: -200, y: 100, width: 800, height: 600 }; - assert.deepEqual( - snapShotAnimationDisplayBounds(displays, source, source), - displays.slice(0, 2).map((display) => display.bounds), - ); - assert.deepEqual( - snapShotAnimationDisplayBounds( - displays, - { x: -1_800, y: 100, width: 800, height: 600 }, - { x: 1_600, y: 200, width: 200, height: 112 }, - ), - displays.slice(0, 3).map((display) => display.bounds), - ); -}); - -it("keeps cross-display handoff surfaces local to each display", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.deepEqual( - flashWindows.map((window) => window.bounds), - [ - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - { x: 0, y: -200, width: 1_440, height: 900 }, - ], - ); - for (const window of flashWindows) { - assert.strictEqual(window.resizeCount, 0); - } - } finally { - transition.dispose(); - } -}); - -it("adds a newly selected destination display without resizing existing surfaces", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true }); - const snapshot = "data:image/png;base64,captured-window"; - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - snapshot, - false, - { x: -1_600, y: 100, width: 1_000, height: 700 }, - ); - assert.lengthOf(flashWindows, 1); - const initialWindow = flashWindows[0]; - - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.strictEqual(flashWindows[0], initialWindow); - assert.deepEqual( - flashWindows.map((window) => window.bounds), - [ - { x: -1_920, y: 0, width: 1_920, height: 1_080 }, - { x: 0, y: -200, width: 1_440, height: 900 }, - ], - ); - for (const window of flashWindows) { - assert.strictEqual(window.resizeCount, 0); - assert.strictEqual(window.showCount, 1); - } - } finally { - transition.dispose(); - } -}); - it("cancels a late display while its snapshot is still decoding", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ boundOverlayToCaptureDisplays: true }); @@ -2203,52 +1211,6 @@ it("cancels a late display while its snapshot is still decoding", async () => { } }); -it("waits for every initial compositor frame before completing capture feedback", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - waitForCompositorFrame: true, - }); - const frames = [Promise.withResolvers(), Promise.withResolvers()]; - const readingFrames = Promise.withResolvers(); - let frameIndex = 0; - let ready = false; - transitionCapturePageMock.mockImplementation(() => { - const frame = frames[frameIndex++]!; - if (frameIndex === frames.length) readingFrames.resolve(); - return frame.promise; - }); - - try { - const begin = transition - .begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - true, - { x: 100, y: 50, width: 1_000, height: 700 }, - ) - .then(() => { - ready = true; - }); - await readingFrames.promise; - frames[0]!.resolve(); - await frames[0]!.promise; - - assert.isFalse(ready); - for (const window of flashWindows) { - assert.strictEqual(window.showCount, 1); - } - frames[1]!.resolve(); - await begin; - - assert.isTrue(ready); - } finally { - for (const frame of frames) frame.resolve(); - transition.dispose(); - } -}); - it("does not flash a dismissed overlay after its initial compositor receipt arrives", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ waitForCompositorFrame: true }); @@ -2282,56 +1244,6 @@ it("does not flash a dismissed overlay after its initial compositor receipt arri } }); -it("waits for every display's prepared compositor frame before starting any flight", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - waitForCompositorFrame: true, - }); - const frames = [Promise.withResolvers(), Promise.withResolvers()]; - const readingFrames = Promise.withResolvers(); - let frameIndex = 0; - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transitionCapturePageMock.mockImplementation(() => { - const frame = frames[frameIndex++]!; - if (frameIndex === frames.length) readingFrames.resolve(); - return frame.promise; - }); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - const landing = transition.waitForLanding("capture-1"); - await readingFrames.promise; - frames[0]!.resolve(); - await new Promise((resolve) => setImmediate(resolve)); - - for (const window of flashWindows) { - assert.deepEqual(window.capturedRegions, [ - { x: 0, y: 0, width: 1, height: 1 }, - { x: 0, y: 0, width: 1, height: 1 }, - ]); - } - - frames[1]!.resolve(); - await landing; - } finally { - for (const frame of frames) frame.resolve(); - transition.dispose(); - } -}); - it("does not start a dismissed transition after its compositor receipt arrives", async () => { flashWindows.length = 0; const transition = new SnapShotTransition({ waitForCompositorFrame: true }); @@ -2362,110 +1274,11 @@ it("does not start a dismissed transition after its compositor receipt arrives", transition.dismiss("capture-1"); assert.isFalse(transition.isAnimating); frame.resolve(); - await landing; - - assert.isTrue(flashWindows[0]?.destroyed); - } finally { - frame.resolve(); - transition.dispose(); - } -}); - -it("keeps flying on the destination display when the capture display fails", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: -1_800, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 100, y: 50, width: 1_000, height: 700 }, - ); - transitionScriptState.rejectFlight = true; - transitionScriptState.heldFlights = []; - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - - let landed = false; - const landing = transition.waitForLanding("capture-1").then(() => { - landed = true; - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.lengthOf(transitionScriptState.heldFlights, 1); - assert.isFalse(landed); - - for (const release of transitionScriptState.heldFlights) release(); - await landing; - } finally { - transitionScriptState.rejectFlight = false; - transitionScriptState.heldFlights = null; - transition.dispose(); - } -}); - -it("keeps same-display motion inside one stable surface", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - boundOverlayToCaptureDisplays: true, - }); - - try { - await transition.begin( - "capture-1", - { x: 100, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - { x: 200, y: 100, width: 1_000, height: 700 }, - ); - transition.animateTo("capture-1", { - frame: { x: 600, y: 400, width: 208, height: 112 }, - backgroundColor: "#fff", - borderColor: "#ccc", - borderWidth: 1, - cornerRadius: 8, - scaleFactor: 1, - }); - await transition.waitForLanding("capture-1"); - - assert.deepEqual(flashWindows[0]?.bounds, { - x: 0, - y: -200, - width: 1_440, - height: 900, - }); - assert.strictEqual(flashWindows[0]?.resizeCount, 0); - } finally { - transition.dispose(); - } -}); - -it("keeps the Windows transition above the revealed main window", async () => { - flashWindows.length = 0; - const transition = new SnapShotTransition({ - alwaysOnTopLevel: "pop-up-menu", - }); - - try { - await transition.begin( - "capture-1", - { x: 100, y: 50, width: 900, height: 600 }, - "data:image/png;base64,", - false, - ); + await landing; - assert.deepEqual(flashWindows[0]?.alwaysOnTopCalls, [[true, "pop-up-menu"]]); + assert.isTrue(flashWindows[0]?.destroyed); } finally { + frame.resolve(); transition.dispose(); } }); @@ -2502,15 +1315,6 @@ it("does not let a failed transition fail landing or capture completion", async } }); -it("bounds source thumbnails for large windows", () => { - assert.deepEqual( - DesktopSnapShot.snapShotThumbnailSize({ - bounds: { x: 0, y: 0, width: 6_000, height: 4_000 }, - } as Parameters[0]), - { width: 2_560, height: 1_600 }, - ); -}); - it.each(["client", "frame"] as const)( "maps KDE %s accessibility coordinates into the decorated screenshot", async (rootArea) => { @@ -2651,8 +1455,6 @@ it.each(["darwin", "win32"] as const)( it.each([ ["darwin", "", "value", 650], - ["win32", "", "value", 650], - ["linux", "x11", "value", 650], ["linux", "wayland", "value", 650], ["darwin", "", "name", 100], ] as const)( @@ -2922,99 +1724,6 @@ it("does not guess GTK4 app text when two unnamed PID windows share the captured } }); -it.each([20, 1_350, 2_999])( - "includes accessibility text as soon as a %d ms read completes", - async (duration) => { - vi.useFakeTimers(); - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - const tree = Promise.withResolvers<{ value: string; children: Array }>(); - const started = Promise.withResolvers(); - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - name: "Mozilla Firefox", - bounds: { x: 0, y: 0, width: 1_373, height: 928 }, - tree: () => { - started.resolve(); - return tree.promise; - }, - }, - ], - }); - - try { - const result = readAccessibleWindowText( - { - title: "Mozilla Firefox", - owner: { processId: 42 }, - bounds: { x: 67, y: 32, width: 1_373, height: 928 }, - }, - "linux", - "Mozilla Firefox", - ); - await started.promise; - await vi.advanceTimersByTimeAsync(duration); - tree.resolve({ value: "Firefox page text", children: [] }); - - assert.strictEqual(await result, "Firefox page text"); - assert.strictEqual(vi.getTimerCount(), 0); - } finally { - tree.resolve({ value: "", children: [] }); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - vi.unstubAllEnvs(); - } - }, -); - -it("falls back to completed flat text when rich traversal reaches the deadline", async () => { - vi.useFakeTimers(); - const richChildren = Promise.withResolvers>(); - const richStarted = Promise.withResolvers(); - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - role: "window", - name: "Editor", - bounds: { x: 0, y: 0, width: 800, height: 600 }, - tree: async () => ({ value: "Complete flat text", children: [] }), - children: () => { - richStarted.resolve(); - return richChildren.promise; - }, - }, - ], - }); - - try { - const result = readAccessibleWindowContext( - { - title: "Editor", - owner: { processId: 42 }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - }, - "darwin", - "Editor", - { width: 1_600, height: 1_200 }, - ); - await richStarted.promise; - await vi.advanceTimersByTimeAsync(3_000); - - assert.deepEqual(await result, { - accessibleText: "Complete flat text", - accessibility: { - format: "flat-text", - text: "Complete flat text", - truncated: false, - }, - }); - } finally { - richChildren.resolve([]); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - } -}); - it("keeps a truncated element tree when the flat text read fails", async () => { const bounds = { x: 0, y: 0, width: 800, height: 600 }; accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -3038,388 +1747,59 @@ it("keeps a truncated element tree when the flat text read fails", async () => { ], }); - const result = await readAccessibleWindowContext( - { title: "Editor", bounds, owner: { processId: 123 } }, - "darwin", - "Editor", - ); - assert.equal(result?.accessibility?.format, "element-tree"); - assert.isTrue(result?.accessibility?.truncated); - assert.deepEqual( - result?.accessibility?.format === "element-tree" - ? result.accessibility.root.children[0]?.bounds - : undefined, - { x: 10, y: 50, width: 700, height: 500 }, - ); -}); - -it("keeps a truncated tree when flat text would not recover any text", async () => { - const bounds = { x: 0, y: 0, width: 800, height: 600 }; - accessibilityByPidMock.mockReset().mockResolvedValue({ - children: async () => [ - { - role: "window", - name: "Editor", - bounds, - tree: async () => ({ name: "Editor", children: [{ name: "Help", children: [] }] }), - children: async () => [ - { - role: "button", - name: "Help", - description: "Help text ".repeat(250), - bounds, - children: async () => [], - }, - ], - }, - ], - }); - - const result = await readAccessibleWindowContext( - { title: "Editor", bounds, owner: { processId: 123 } }, - "darwin", - "Editor", - ); - assert.equal(result?.accessibility?.format, "element-tree"); - assert.isTrue(result?.accessibility?.truncated); - assert.include( - result?.accessibility?.format === "element-tree" - ? result.accessibility.root.children[0]?.description - : undefined, - "Help text", - ); -}); - -it("times out after three seconds without overlapping the outstanding accessibility read", async () => { - vi.useFakeTimers(); - accessibilityByPidMock.mockReset(); - const read = Promise.withResolvers<{ children: () => Promise> }>(); - const started = Promise.withResolvers(); - accessibilityByPidMock.mockImplementationOnce(() => { - started.resolve(); - return read.promise; - }); - const active = { - title: "main.ts", - owner: { processId: 42 }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - } satisfies SnapShotAccessibility.AccessibleWindowIdentity; - - try { - const first = readAccessibleWindowText(active, "darwin", "main.ts"); - let settled = false; - void first.then(() => { - settled = true; - }); - await started.promise; - await vi.advanceTimersByTimeAsync(2_999); - assert.isFalse(settled); - await vi.advanceTimersByTimeAsync(1); - assert.isUndefined(await first); - assert.strictEqual(vi.getTimerCount(), 0); - assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts")); - assert.strictEqual(accessibilityByPidMock.mock.calls.length, 1); - - read.resolve({ children: async () => [] }); - await vi.advanceTimersByTimeAsync(0); - accessibilityByPidMock.mockResolvedValueOnce({ children: async () => [] }); - assert.isUndefined(await readAccessibleWindowText(active, "darwin", "main.ts")); - assert.strictEqual(accessibilityByPidMock.mock.calls.length, 2); - } finally { - read.resolve({ children: async () => [] }); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - } -}); - -it("uses native opacity for a short-lived flash", async () => { - vi.useFakeTimers(); - flashWindows.length = 0; - const flash = new DesktopSnapShot.SnapShotFlash(); - const bounds = { x: 10, y: 20, width: 800, height: 600 }; - - try { - await flash.showAnimated(bounds); - - assert.lengthOf(flashWindows, 1); - assert.strictEqual(flashWindows[0]?.kind, "base"); - assert.strictEqual(flashWindows[0]?.options.transparent, false); - assert.strictEqual(flashWindows[0]?.loadCount, 0); - assert.deepEqual(flashWindows[0]?.bounds, bounds); - assert.strictEqual(flashWindows[0]?.showCount, 1); - assert.lengthOf(flashWindows[0]?.scripts ?? [], 0); - await vi.advanceTimersByTimeAsync(180); - assert.isTrue(flashWindows[0]?.destroyed); - assert.isAbove(flashWindows[0]?.opacities.length ?? 0, 1); - assert.strictEqual(vi.getTimerCount(), 0); - } finally { - vi.useRealTimers(); - } -}); - -it.effect("does not request permissions or create the flash during desktop startup", () => { - flashWindows.length = 0; - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotFlash: true, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.initialize; - assert.lengthOf(flashWindows, 0); - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.lengthOf(openExternalMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layerTest("darwin", {}, Option.some(settings)))); -}); - -it.effect("keeps snapshots disabled when client settings cannot be read at startup", () => { - const readError = new DesktopClientSettings.DesktopClientSettingsReadError({ - operation: "read-file", - path: "/state/client-settings.json", - cause: new Error("unavailable"), - }); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.initialize; - assert.isFalse((yield* service.state).shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("win32", {}, Option.none(), Effect.fail(readError)))); -}); - -it.effect("does not request macOS permissions while synchronizing enabled settings", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[false]]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - assert.lengthOf(openExternalMock.mock.calls, 0); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("requests macOS permissions only for an explicit enable action", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.requestPermissions(true); - - assert.deepEqual(accessibilityTrustedMock.mock.calls, [[true]]); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("does not request macOS accessibility permission when capture data is disabled", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("not-determined"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.requestPermissions(false); - - assert.lengthOf(accessibilityTrustedMock.mock.calls, 0); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("reports macOS permission status and requests each permission on its own", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("granted"); - getSourcesMock.mockReset().mockResolvedValue([]); - openExternalMock.mockClear(); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.deepEqual(state.macPermissions, { screenRecording: true, accessibility: false }); - - yield* service.setup("allow-accessibility"); - assert.deepEqual(accessibilityTrustedMock.mock.calls.at(-1), [true]); - assert.lengthOf(getSourcesMock.mock.calls, 0); - - mediaAccessStatusMock.mockReturnValue("not-determined"); - yield* service.setup("allow-screen-recording"); - assert.lengthOf(getSourcesMock.mock.calls, 1); - assert.lengthOf(openExternalMock.mock.calls, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("flags revoked macOS permissions on read and re-registers once they return", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(true); - mediaAccessStatusMock.mockReset().mockReturnValue("granted"); - registerShortcutMock.mockReset().mockReturnValue(true); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); - assert.isNull((yield* service.state).message); - - mediaAccessStatusMock.mockReturnValue("denied"); - const revoked = yield* service.state; - assert.equal( - revoked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", - ); - assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true }); - - accessibilityTrustedMock.mockReturnValue(false); - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: false, - }); - const blocked = yield* service.state; - assert.equal( - blocked.message, - "Allow Screen Recording in System Settings, then restart T3 Code.", - ); - assert.isFalse(blocked.shortcutRegistered); - - mediaAccessStatusMock.mockReturnValue("granted"); - const recovered = yield* service.state; - assert.isNull(recovered.message); - assert.isTrue(recovered.shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("rejects macOS permission actions off macOS and omits macPermissions there", () => { - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.isUndefined(state.macPermissions); - assert.isTrue(state.windows); - const error = yield* service.setup("allow-screen-recording").pipe(Effect.flip); - assert.equal(error.reason, "unsupported-session"); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("registers macOS capture without accessibility permission when data is disabled", () => { - accessibilityTrustedMock.mockReset().mockReturnValue(false); - mediaAccessStatusMock.mockReset().mockReturnValue("granted"); - registerShortcutMock.mockReset().mockReturnValue(true); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotIncludeAccessibility: false, - }); - - assert.lengthOf(accessibilityTrustedMock.mock.calls, 0); - assert.isTrue((yield* service.state).shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("starts the Shift listener outside the Electron main process", () => { - shortcutForkArgs.length = 0; - shortcutForkOptions.length = 0; - shortcutProcesses.length = 0; - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - - assert.isTrue(state.shortcutRegistered); - assert.lengthOf(shortcutProcesses, 1); - assert.deepEqual(shortcutForkArgs[0], ["shift"]); - assert.strictEqual(shortcutForkOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1"); - - shortcutProcesses[0]?.emit("exit", 1); - yield* Effect.promise(() => new Promise((resolve) => queueMicrotask(resolve))); - assert.isFalse((yield* service.state).shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("win32"))); + const result = await readAccessibleWindowContext( + { title: "Editor", bounds, owner: { processId: 123 } }, + "darwin", + "Editor", + ); + assert.equal(result?.accessibility?.format, "element-tree"); + assert.isTrue(result?.accessibility?.truncated); + assert.deepEqual( + result?.accessibility?.format === "element-tree" + ? result.accessibility.root.children[0]?.bounds + : undefined, + { x: 10, y: 50, width: 700, height: 500 }, + ); }); -it.effect("passes the configured modifier pair to the listener process", () => { - shortcutForkArgs.length = 0; - shortcutProcesses.length = 0; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { kind: "modifier-pair", modifier: "meta" }, - } satisfies ClientSettings; +it.effect("flags revoked macOS permissions on read and re-registers once they return", () => { + accessibilityTrustedMock.mockReset().mockReturnValue(true); + mediaAccessStatusMock.mockReset().mockReturnValue("granted"); + registerShortcutMock.mockReset().mockReturnValue(true); return Effect.scoped( Effect.gen(function* () { const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - - assert.isTrue(state.shortcutRegistered); - assert.deepEqual(shortcutForkArgs[0], ["meta"]); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); + yield* service.configure({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }); + assert.isNull((yield* service.state).message); -it.effect("registers a configured key chord instead of the Shift listener", () => { - registerShortcutMock.mockReset().mockReturnValue(true); - shortcutProcesses.length = 0; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { - key: "k", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }, - }; + mediaAccessStatusMock.mockReturnValue("denied"); + const revoked = yield* service.state; + assert.equal( + revoked.message, + "Allow Screen Recording in System Settings, then restart T3 Code.", + ); + assert.deepEqual(revoked.macPermissions, { screenRecording: false, accessibility: true }); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); + accessibilityTrustedMock.mockReturnValue(false); + yield* service.configure({ + ...DEFAULT_CLIENT_SETTINGS, + snapShotEnabled: true, + snapShotIncludeAccessibility: false, + }); + const blocked = yield* service.state; + assert.equal( + blocked.message, + "Allow Screen Recording in System Settings, then restart T3 Code.", + ); + assert.isFalse(blocked.shortcutRegistered); - assert.isTrue((yield* service.state).shortcutRegistered); - assert.strictEqual(registerShortcutMock.mock.calls[0]?.[0], "Control+Alt+K"); - assert.lengthOf(shortcutProcesses, 0); + mediaAccessStatusMock.mockReturnValue("granted"); + const recovered = yield* service.state; + assert.isNull(recovered.message); + assert.isTrue(recovered.shortcutRegistered); }), - ).pipe(Effect.provide(layerTest("win32"))); + ).pipe(Effect.provide(layerTest("darwin"))); }); it.effect("an unrelated client-setting change keeps the registered shortcut", () => { @@ -3481,133 +1861,6 @@ it.effect("capture fails closed while snapshots are disabled", () => { ).pipe(Effect.provide(layerTest("win32"))); }); -it.effect("keeps shortcut registration errors off the capture status", () => { - registerShortcutMock.mockReset().mockReturnValue(false); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: { - key: "k", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.isNull(state.message); - assert.equal( - state.shortcutMessage, - "This shortcut is already used by the system or another app.", - ); - }), - ).pipe(Effect.provide(layerTest("win32"))); -}); - -it.effect("uses an external Niri shortcut without registering an Electron accelerator", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - vi.stubEnv("XDG_CURRENT_DESKTOP", "niri"); - vi.stubEnv("NIRI_SOCKET", "/test/niri.sock"); - registerShortcutMock.mockReset(); - niriShortcutStopMock.mockReset(); - niriShortcutMock.mockReset().mockResolvedValue(niriShortcutStopMock); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - const state = yield* service.state; - assert.isFalse(state.shortcutRegistered); - assert.include(state.shortcutBinding, "gdbus"); - assert.match(state.shortcutBinding ?? "", /^Ctrl\+Shift\+2 repeat=false \{/); - assert.include(state.shortcutMessage, "Niri config"); - assert.isTrue(state.shortcutActionRegistered); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.lengthOf(niriShortcutMock.mock.calls, 1); - assert.isFalse((yield* service.checkShortcut(settings.snapShotShortcut)).available); - yield* Effect.promise(async () => { - await niriShortcutMock.mock.calls[0]![1](); - }); - assert.isTrue((yield* service.state).shortcutVerified); - yield* service.configure({ ...settings, snapShotEnabled: false }); - assert.lengthOf(niriShortcutStopMock.mock.calls, 1); - assert.isUndefined((yield* service.state).shortcutBinding); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect("defers ordinary Wayland shortcut registration until settings are applied", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(false); - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const conflict = yield* service.checkShortcut({ - key: "c", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: false, - modKey: false, - }); - const available = yield* service.checkShortcut({ - key: "2", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - }); - assert.isFalse(conflict.available); - assert.isTrue(available.available); - assert.match(available.message ?? "", /desktop will confirm/); - - const pair = yield* service.checkShortcut({ - kind: "modifier-pair", - modifier: "meta", - }); - assert.isFalse(pair.available); - assert.match(pair.message ?? "", /Modifier-pair shortcuts aren't available/); - assert.lengthOf(registerShortcutMock.mock.calls, 0); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect.each(["GNOME", "KDE", "niri", "Hyprland"] as const)( - "identifies %s in setup even when its capability check fails", - (desktop) => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - vi.stubEnv("XDG_CURRENT_DESKTOP", desktop); - linuxBackendMock.mockRejectedValueOnce(new Error("Capability check failed")); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const state = yield* service.state; - assert.equal(state.linuxDesktop, desktop.toLowerCase()); - assert.equal(state.message, "Capability check failed"); - assert.isFalse(state.shortcutVerified); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }, -); - it.effect( "keeps saved preferences but rechecks access and shortcut delivery after changing desktops", () => { @@ -3682,85 +1935,6 @@ it.effect( }, ); -it.effect("does not register a Wayland modifier-pair shortcut when enabled", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - const settings = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.isFalse(state.shortcutRegistered); - assert.deepEqual(state.shortcut, DEFAULT_CLIENT_SETTINGS.snapShotShortcut); - assert.match(state.shortcutMessage ?? "", /Modifier-pair shortcuts aren't available/); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - -it.effect("exposes pending portal permission and then the real assigned shortcut", () => { - vi.stubEnv("XDG_SESSION_TYPE", "wayland"); - registerShortcutMock.mockReset().mockReturnValue(true); - nextPortalState.value = { - shortcutRegistered: false, - shortcutPending: true, - shortcutMessage: "Waiting for permission", - }; - const shortcut = { - key: "2", - metaKey: false, - ctrlKey: true, - shiftKey: true, - altKey: false, - modKey: false, - } as const; - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotShortcut: shortcut, - }; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure(settings); - - const state = yield* service.state; - assert.lengthOf(registerShortcutMock.mock.calls, 0); - assert.isFalse(state.shortcutRegistered); - assert.isTrue(state.shortcutPending); - assert.deepEqual(state.shortcut, shortcut); - assert.equal(state.shortcutMessage, "Waiting for permission"); - - portalShortcutInstances[0]!.state = { - shortcutRegistered: true, - shortcutPending: false, - shortcutLabel: "Ctrl+Shift+7", - shortcutMessage: null, - }; - assert.equal((yield* service.state).shortcutLabel, "Ctrl+Shift+7"); - assert.isTrue((yield* service.state).shortcutRegistered); - portalShortcutInstances[0]!.state = { - shortcutRegistered: false, - shortcutPending: false, - shortcutMessage: "Permission denied", - }; - const failed = yield* service.state; - assert.isFalse(failed.shortcutRegistered); - assert.equal(failed.shortcutMessage, "Permission denied"); - }), - ).pipe( - Effect.provide(layerTest("linux")), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); -}); - it.effect("verifies only native shortcut delivery and captures normally", () => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); vi.stubEnv("XDG_CURRENT_DESKTOP", "test-desktop"); @@ -3917,58 +2091,6 @@ it.effect( }, ); -it.effect("advises about the system menu for a meta pair on Windows", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const result = yield* service.checkShortcut({ kind: "modifier-pair", modifier: "meta" }); - assert.isTrue(result.available); - assert.match(result.message ?? "", /Super \+ Super is observed/); - assert.match(result.message ?? "", /system's own menu/); - }), - ).pipe(Effect.provide(layerTest("win32"))), -); - -it.effect("probes macOS modifier pairs with the flags poller", () => { - spawnedPollers.length = 0; - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const result = yield* service.checkShortcut({ kind: "both-shift-keys" }); - assert.isTrue(result.available); - assert.match(result.message ?? "", /Shift \+ Shift is observed/); - assert.notMatch(result.message ?? "", /Input Monitoring/); - assert.lengthOf(spawnedPollers, 1); - assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["2", "4"]); - assert.strictEqual(spawnedPollers[0]?.kill.mock.calls.length, 1); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - -it.effect("registers macOS modifier pairs through the flags poller", () => { - spawnedPollers.length = 0; - shortcutProcesses.length = 0; - accessibilityTrustedMock.mockReturnValue(true); - mediaAccessStatusMock.mockReturnValue("granted"); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotShortcut: { kind: "modifier-pair", modifier: "meta" }, - } satisfies ClientSettings; - - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - yield* service.configure({ ...settings, snapShotEnabled: true }); - const state = yield* service.state; - - assert.lengthOf(shortcutProcesses, 0); - assert.lengthOf(spawnedPollers, 1); - assert.deepEqual(spawnedPollers[0]?.args.slice(-2), ["8", "16"]); - assert.isTrue(state.shortcutRegistered); - }), - ).pipe(Effect.provide(layerTest("darwin"))); -}); - it.effect("waits to apply settings while permissions are pending", () => { accessibilityTrustedMock.mockReturnValue(true); mediaAccessStatusMock.mockReturnValueOnce("not-determined").mockReturnValue("granted"); @@ -4052,89 +2174,3 @@ it.effect.each([false, true])( ); }, ); - -const macTestCaptureLayer = layerTest("darwin", { - makeTempDirectoryScoped: () => Effect.succeed("/tmp/setup-test"), - readDirectory: () => Effect.succeed([]), -}); -const macTestCaptureWindow = { - platform: "macos", - id: 42, - title: "Setup", - owner: { name: "T3 Code", processId: 123, path: "/Applications/T3 Code.app" }, - bounds: { x: 0, y: 0, width: 800, height: 600 }, -}; - -it.effect("names the failed macOS window lookup without repeating what it printed", () => { - const script = 'ObjC.import("CoreGraphics"); function run() { return ""; }'; - const stderr = "execution error: Error: Not authorized to send Apple events. (-1743)"; - activeWindowMock - .mockReset() - .mockRejectedValue( - Object.assign( - new Error(`Command failed: /usr/bin/osascript -l JavaScript -e ${script}\n${stderr}`), - { cmd: `/usr/bin/osascript -l JavaScript -e ${script}`, code: 1, killed: false }, - ), - ); - macCaptureMock.mockReset(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - 'Could not capture a test snapshot. Finding the front window failed: osascript exited with code 1. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', - ); - assert.include(String(error.cause), stderr); - assert.equal(macCaptureMock.mock.calls.length, 0); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("says when macOS has no window to test capture", () => { - activeWindowMock.mockReset().mockResolvedValue(undefined); - macCaptureMock.mockReset(); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - "Could not capture a test snapshot. No window is available to capture.", - ); - assert.equal(macCaptureMock.mock.calls.length, 0); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("says when the macOS test capture command timed out", () => { - activeWindowMock.mockReset().mockResolvedValue(macTestCaptureWindow); - macCaptureMock.mockReset().mockRejectedValue( - Object.assign(new Error("Command failed: /usr/sbin/screencapture -l 42 -o -x"), { - code: null, - killed: true, - signal: "SIGTERM", - }), - ); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal( - error.message, - 'Could not capture a test snapshot. Capturing the window timed out. macOS grants Screen Recording and Accessibility to each app separately. In System Settings, open Privacy & Security and check that "T3 Code (Dev)" is allowed for both.', - ); - }), - ).pipe(Effect.provide(macTestCaptureLayer)); -}); - -it.effect("rejects macOS test capture on other platforms", () => - Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopSnapShot.make; - const error = yield* service.setup("test-mac-capture").pipe(Effect.flip); - assert.equal(error.reason, "unsupported-session"); - assert.equal(error.message, "Test capture requires macOS."); - }), - ).pipe(Effect.provide(layerTest("win32"))), -); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 569bf732df65..164c97b4f22f 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -289,11 +289,11 @@ function captureMode(platform: NodeJS.Platform): DesktopSnapShotState["mode"] { return platform === "darwin" || platform === "win32" ? "direct" : "unavailable"; } -export function shouldAnimateSnapShot(settings: SnapShotSystemAnimationSettings): boolean { +function shouldAnimateSnapShot(settings: SnapShotSystemAnimationSettings): boolean { return settings.shouldRenderRichAnimation && !settings.prefersReducedMotion; } -export function snapShotThumbnailSize(active: ActiveWindow | undefined): Electron.Size { +function snapShotThumbnailSize(active: ActiveWindow | undefined): Electron.Size { if (!active) return { width: 2_560, height: 1_600 }; return { width: Math.min(Math.max(active.bounds.width, 1), MAX_CAPTURE_WIDTH), @@ -301,7 +301,7 @@ export function snapShotThumbnailSize(active: ActiveWindow | undefined): Electro }; } -export function snapShotIconDataUrl( +function snapShotIconDataUrl( ...icons: ReadonlyArray ): string | undefined { const icon = icons.find((candidate): candidate is Electron.NativeImage => @@ -324,7 +324,7 @@ async function appFileIcon( return Electron.app.getFileIcon(path, { size: "normal" }).catch(() => undefined); } -export async function iconDataUrl( +async function iconDataUrl( source: { readonly appIcon?: Electron.NativeImage | null }, active: ActiveWindow | undefined, platform: NodeJS.Platform, @@ -665,7 +665,7 @@ export class SnapShotFlash { } } -export function snapShotFlashBounds( +function snapShotFlashBounds( active: ActiveWindow | undefined, platform: NodeJS.Platform, ): Electron.Rectangle { diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts index af2052c6a03a..fd0bfdfd15dd 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.test.ts @@ -5,11 +5,7 @@ import * as NodePath from "node:path"; import * as NodeEvents from "node:events"; import type { Message, MessageBus } from "dbus-next"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { - GnomeCaptureSetup, - installGnomeCaptureBundle, - isGnomeCaptureSession, -} from "./GnomeCaptureSetup.ts"; +import { GnomeCaptureSetup, installGnomeCaptureBundle } from "./GnomeCaptureSetup.ts"; import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; let dataHome: string; @@ -117,13 +113,9 @@ it("refuses to replace symlinks or downgrade a newer extension", async () => { }); it.each([ - [{}, "not-installed"], - [{ state: 2, version: 3 }, "disabled"], [{ state: 1, version: 3 }, "enabled"], - [{ state: 1, version: 1 }, "update-required"], [{ state: 1, version: 2, shell: "51.0" }, "update-required"], [{ state: 2, version: 3, enabled: false }, "extensions-disabled"], - [{ state: 3, version: 3, error: "Load failed" }, "error"], [{ shell: "52.0" }, "unsupported"], ] as const)("reports GNOME setup state %j", async (options, status) => { const { setup, call } = fixture(options); @@ -147,31 +139,6 @@ it("requires login for local installs and updates until the new version is loade setup.close(); }); -it("installs only on explicit action without invoking remote installation", async () => { - const { setup, call } = fixture(); - await setup.perform("install-extension"); - expect(await NodeFSP.readdir(installedPath())).toHaveLength(GNOME_CAPTURE_FILES.length); - expect( - call.mock.calls.every(([message]) => ["GetAll", "GetExtensionInfo"].includes(message.member)), - ).toBe(true); - setup.close(); -}); - -it("enables and disables only the capture UUID", async () => { - const { setup, call } = fixture({ state: 2, version: 3 }); - await setup.perform("enable-extension"); - await setup.perform("disable-extension"); - for (const member of ["EnableExtension", "DisableExtension"]) { - expect(call.mock.calls.find(([message]) => message.member === member)?.[0]).toMatchObject({ - destination: "org.gnome.Shell", - path: "/org/gnome/Shell", - signature: "s", - body: [GNOME_CAPTURE_UUID], - }); - } - setup.close(); -}); - it("never changes the global user-extensions preference", async () => { const { setup, call } = fixture({ state: 2, version: 3, enabled: false }); await expect(setup.perform("enable-extension")).rejects.toThrow("GNOME has disabled"); @@ -186,9 +153,3 @@ it("surfaces desktop rejection and disconnect as actionable failures", async () expect(await setup.state()).toEqual({ status: "error", message: "Session bus disconnected" }); setup.close(); }); - -it("does not offer host extension installation in another desktop or sandbox", () => { - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBe(true); - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "niri", GDMSESSION: "gnome" })).toBe(false); - expect(isGnomeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME", FLATPAK_ID: "com.t3" })).toBe(false); -}); diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts index cc4ada0d57ab..5979bf3b000c 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts @@ -7,7 +7,6 @@ import * as Schema from "effect/Schema"; import type { DesktopCaptureExtensionState } from "@t3tools/contracts"; import { GNOME_CAPTURE_FILES, GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; -export { isGnomeCaptureSession } from "./linuxCaptureSession.ts"; const SHELL = "org.gnome.Shell"; const SHELL_PATH = "/org/gnome/Shell"; diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts index fe55319ecea4..525e57b34938 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.test.ts @@ -17,7 +17,6 @@ import { captureHyprlandWindow, hyprlandCaptureExecutable, hyprlandCaptureShortcut, - isHyprlandCaptureSession, } from "./HyprlandSnapShot.ts"; let directory: string; @@ -67,22 +66,6 @@ beforeEach(async () => { afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); - -it("does not select Hyprland from a stale socket environment or sandbox", () => { - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe(true); - expect( - isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", HYPRLAND_INSTANCE_SIGNATURE: "old" }), - ).toBe(false); - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland", FLATPAK_ID: "app" })).toBe( - false, - ); - expect(isHyprlandCaptureSession({ XDG_CURRENT_DESKTOP: "Hyprland", SNAP: "app" })).toBe(false); -}); -it("discovery neither installs a helper nor requests a screenshot", async () => { - expect((await setup.state()).status).toBe("not-installed"); - expect(execute).not.toHaveBeenCalled(); - await expect(NodeFSP.stat(paths.dataHome)).rejects.toMatchObject({ code: "ENOENT" }); -}); it("installs offline at a stable executable path and probes only capabilities", async () => { await setup.perform("install-hyprland-helper"); expect((await NodeFSP.stat(hyprlandCaptureExecutable(paths))).mode & 0o777).toBe(0o755); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.test.ts b/apps/desktop/src/snapShot/KdeSnapShot.test.ts index 2093be868e67..199af10e8ff9 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.test.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.test.ts @@ -13,13 +13,7 @@ vi.mock("electron", () => ({ createFromBuffer: () => ({ isEmpty: () => false, getSize: () => ({ width: 2, height: 1 }) }), }, })); -import { - KdeCaptureSetup, - captureKdeWindow, - isKdeCaptureSession, - kdeCaptureDesktopEntry, - kdeCapturePaths, -} from "./KdeSnapShot.ts"; +import { KdeCaptureSetup, captureKdeWindow, kdeCapturePaths } from "./KdeSnapShot.ts"; let directory: string; let paths: { bundle: string; dataHome: string }; @@ -73,48 +67,6 @@ afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); -it("detects KDE only outside a sandbox", () => { - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE" })).toBe(true); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "GNOME" })).toBe(false); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", FLATPAK_ID: "app" })).toBe(false); - expect(isKdeCaptureSession({ XDG_CURRENT_DESKTOP: "KDE", SNAP: "/snap/app" })).toBe(false); -}); - -it("does not install or request screenshots during initial discovery", async () => { - expect((await setup.state()).status).toBe("not-installed"); - expect(execute).not.toHaveBeenCalled(); - await expect(NodeFSP.stat(paths.dataHome)).rejects.toMatchObject({ code: "ENOENT" }); -}); - -it("installs offline at a stable path and verifies permissions using the installed identity", async () => { - await setup.perform("install-kde-helper"); - const { executable, desktop } = kdeCapturePaths(paths); - expect(await NodeFSP.readFile(executable, "utf8")).toBe("bundled executable"); - expect((await NodeFSP.stat(executable)).mode & 0o777).toBe(0o755); - expect(await NodeFSP.readFile(desktop, "utf8")).toBe(kdeCaptureDesktopEntry(executable)); - expect(kdeCaptureDesktopEntry(executable)).toContain(`Exec="${executable}" check`); - expect((await setup.state()).status).toBe("ready"); - expect((await setup.state()).feedbackAvailable).toBe(true); - expect(execute.mock.calls.map(([file, args]) => [file, args])).toEqual([ - ["kbuildsycoca6", ["--noincremental"]], - [ - "systemd-run", - [ - "--user", - "--quiet", - "--wait", - "--collect", - "--pipe", - "--service-type=exec", - "kbuildsycoca6", - "--noincremental", - ], - ], - [executable, ["check"]], - [executable, ["check"]], - ]); -}); - it("does not mistake installed files for KDE authorization, or fall back to a picker on denial", async () => { await setup.perform("install-kde-helper"); denial = true; diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 5a90aa818fac..bfd6b2984147 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -29,7 +29,7 @@ export function kdeCapturePaths(paths: KdeCapturePaths) { }; } -export function kdeCaptureDesktopEntry(executable: string): string { +function kdeCaptureDesktopEntry(executable: string): string { return [ "[Desktop Entry]", "Type=Application", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts deleted file mode 100644 index 1cb75134ad07..000000000000 --- a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Isolated D-Bus integration fixture, never the user's session bus. -import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; -import * as NodeURL from "node:url"; -import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; -import { expect, it, vi } from "vite-plus/test"; - -vi.mock("electron", () => ({ - nativeImage: { - createFromBuffer: () => ({ - isEmpty: () => false, - getSize: () => ({ width: 800, height: 600 }), - }), - }, -})); -import { LinuxCaptureConnection } from "./LinuxSnapShot.ts"; -import { startNiriCaptureShortcut } from "./NiriCaptureShortcut.ts"; -import { GnomeCaptureSetup } from "./GnomeCaptureSetup.ts"; -import { GNOME_CAPTURE_UUID } from "./gnomeCaptureBundle.ts"; - -const hasDbus = - NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0 && - NodeChildProcess.spawnSync("gdbus", ["help"]).status === 0; - -it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", async () => { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-private-dbus-")); - let daemon: NodeChildProcess.ChildProcess | undefined; - let server: MessageBus | undefined; - const clients: LinuxCaptureConnection[] = []; - let stopNiriShortcut: (() => void) | undefined; - try { - vi.stubEnv("XDG_CURRENT_DESKTOP", "GNOME"); - vi.stubEnv("FLATPAK_ID", ""); - vi.stubEnv("SNAP", ""); - daemon = NodeChildProcess.spawn( - "dbus-daemon", - [ - "--session", - "--nofork", - "--nopidfile", - "--print-address", - `--address=unix:path=${NodePath.join(directory, "bus")}`, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const lines = NodeReadline.createInterface({ input: daemon.stdout! }); - const [address] = await Promise.race([ - NodeEvents.EventEmitter.once(lines, "line"), - NodeEvents.EventEmitter.once(daemon, "exit").then(() => { - throw new Error("Private D-Bus daemon failed to start."); - }), - ]); - lines.close(); - server = sessionBus({ busAddress: String(address) }); - server.on("error", () => undefined); - await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.gnome.Shell.Extensions.T3SnapShot", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.gnome.Shell", NameFlag.DO_NOT_QUEUE); - await server.requestName("org.kde.KWin.ScreenShot2", NameFlag.DO_NOT_QUEUE); - const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); - const imagePath = NodePath.join(directory, "image.png"); - await NodeFSP.writeFile(imagePath, png); - let portalVersion = 3; - let extensionVersion = 1; - let feedbackArgs: unknown[] | undefined; - let activateTitle: unknown; - let animateFrame: unknown[] | undefined; - let target: unknown; - let clientName: string | undefined; - let shellExtensionState = 2; - server.addMethodHandler((message: Message) => { - if (message.member === "Register") { - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "GetAll") { - const properties = - message.body[0] === "org.gnome.Shell.Extensions" - ? { - ShellVersion: new Variant("s", "50.0"), - UserExtensionsEnabled: new Variant("b", true), - } - : message.body[0] === "org.kde.KWin.ScreenShot2" - ? { Version: new Variant("u", 5) } - : message.body[0] === "org.freedesktop.portal.Screenshot" - ? { - version: new Variant("u", portalVersion), - AvailableTargets: new Variant("u", 8), - } - : { Version: new Variant("u", extensionVersion) }; - server!.send(Message.newMethodReturn(message, "a{sv}", [properties])); - } else if (message.member === "GetExtensionInfo") { - server!.send( - Message.newMethodReturn(message, "a{sv}", [ - { state: new Variant("d", shellExtensionState), version: new Variant("d", 3) }, - ]), - ); - } else if (message.member === "EnableExtension" || message.member === "DisableExtension") { - expect(message.body).toEqual([GNOME_CAPTURE_UUID]); - shellExtensionState = message.member === "EnableExtension" ? 1 : 2; - server!.send(Message.newMethodReturn(message, "b", [true])); - } else if (message.member === "Screenshot") { - const options = message.body[1] as Record>; - target = options.target?.value; - const path = `/org/freedesktop/portal/desktop/request/${message.sender.slice(1).replaceAll(".", "_")}/${options.handle_token?.value}`; - // The real transport also has to handle Response before the method reply. - const signal = Message.newSignal( - path, - "org.freedesktop.portal.Request", - "Response", - "ua{sv}", - [0, { uri: new Variant("s", NodeURL.pathToFileURL(imagePath).href) }], - ); - signal.destination = message.sender; - server!.send(signal); - server!.send(Message.newMethodReturn(message, "o", [path])); - } else if (message.member === "Activate") { - activateTitle = message.body[0]; - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Animate") { - animateFrame = message.body; - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Capture" || message.member === "CaptureWithFeedback") { - const feedback = message.member === "CaptureWithFeedback"; - if (feedback) feedbackArgs = message.body; - clientName = message.sender; - server!.send( - Message.newMethodReturn(message, feedback ? "aysb" : "ays", [ - png, - JSON.stringify({ - title: "Editor", - appName: "Editor", - appIdentifier: "editor.desktop", - processId: 42, - bounds: { x: 0, y: 0, width: 800, height: 600 }, - }), - ...(feedback ? [true] : []), - ]), - ); - } else return false; - return true; - }); - const connect = () => { - const client = new LinuxCaptureConnection(sessionBus({ busAddress: String(address) })); - clients.push(client); - return client; - }; - const setup = new GnomeCaptureSetup( - { - bundle: NodePath.resolve(import.meta.dirname, "../../gnome-extension"), - dataHome: directory, - }, - sessionBus({ busAddress: String(address) }), - ); - try { - expect((await setup.state()).status).toBe("disabled"); - await setup.perform("enable-extension"); - expect((await setup.state()).status).toBe("enabled"); - await setup.perform("disable-extension"); - expect((await setup.state()).status).toBe("disabled"); - } finally { - setup.close(); - } - const portal = connect(); - expect(await portal.backend("com.t3tools.T3Code")).toBe("screenshot-portal"); - expect(await portal.capturePortal()).toEqual({ png }); - expect(target).toBe(8); - portalVersion = 2; - const extension = connect(); - expect(await extension.backend("com.t3tools.T3Code")).toBe("gnome-extension"); - expect(await extension.captureExtension("com.t3tools.T3Code")).toMatchObject({ - png, - window: { processId: 42 }, - }); - const owner = await server.call( - new Message({ - destination: "org.freedesktop.DBus", - path: "/org/freedesktop/DBus", - interface: "org.freedesktop.DBus", - member: "GetNameOwner", - signature: "s", - body: ["com.t3tools.T3Code.SnapShot"], - }), - ); - expect(owner?.body[0]).toBe(clientName); - extension.close(); - extensionVersion = 2; - const updated = connect(); - expect(await updated.backend("com.t3tools.T3Code")).toBe("gnome-extension"); - const snapshot = await updated.captureExtension("com.t3tools.T3Code", { - flash: true, - animate: true, - }); - expect(snapshot.feedback?.animationStarted).toBe(true); - await snapshot.feedback!.activate("T3 Code"); - await snapshot.feedback!.animateTo({ x: 0.1, y: 0.8, width: 0.2, height: 0.1 }); - await snapshot.feedback!.complete(); - expect(feedbackArgs).toEqual([true, true]); - expect(activateTitle).toBe("T3 Code"); - expect(animateFrame).toEqual([0.1, 0.8, 0.2, 0.1]); - vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); - const kde = connect(); - expect(await kde.backend("com.t3tools.T3Code")).toBe("kde"); - expect(kde.feedbackAvailable).toBe(false); - kde.close(); - const triggered = vi.fn(); - const failed = vi.fn(); - const niriBus = sessionBus({ busAddress: String(address) }); - stopNiriShortcut = await startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - niriBus, - ); - const command = [ - "call", - "--session", - "--dest", - "com.t3tools.T3Code.NiriTest.SnapShot", - "--object-path", - "/com/t3tools/SnapShot", - "--method", - "com.t3tools.SnapShot.Capture", - ]; - await new Promise((resolve, reject) => { - NodeChildProcess.execFile( - "gdbus", - command, - { - env: { ...process.env, DBUS_SESSION_BUS_ADDRESS: String(address) }, - timeout: 3_000, - }, - (error) => (error ? reject(error) : resolve()), - ); - }); - expect(triggered).toHaveBeenCalledOnce(); - expect(failed).not.toHaveBeenCalled(); - const invalid = server.call( - new Message({ - destination: "com.t3tools.T3Code.NiriTest.SnapShot", - path: "/com/t3tools/SnapShot", - interface: "com.t3tools.SnapShot", - member: "Capture", - signature: "s", - body: ["not allowed"], - }), - ); - await expect(invalid).rejects.toThrow("Capture takes no arguments"); - expect(triggered).toHaveBeenCalledOnce(); - await expect( - startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - sessionBus({ busAddress: String(address) }), - ), - ).rejects.toThrow("already owns"); - stopNiriShortcut(); - const restarted = await startNiriCaptureShortcut( - "com.t3tools.T3Code.NiriTest", - triggered, - failed, - sessionBus({ busAddress: String(address) }), - ); - restarted(); - } finally { - vi.unstubAllEnvs(); - stopNiriShortcut?.(); - for (const client of clients) client.close(); - server?.disconnect(); - if (daemon && daemon.exitCode === null) { - const exited = NodeEvents.EventEmitter.once(daemon, "exit"); - daemon.kill("SIGTERM"); - await exited; - } - await NodeFSP.rm(directory, { recursive: true }); - } -}); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts index 0ea9c08f935c..49aa4dd72177 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.test.ts @@ -33,12 +33,7 @@ vi.mock("electron", () => ({ }, })); -import { - captureLinuxWindow, - getLinuxCaptureSupport, - readPortalPng, - resizeLinuxCapture, -} from "./LinuxSnapShot.ts"; +import { captureLinuxWindow, getLinuxCaptureSupport, readPortalPng } from "./LinuxSnapShot.ts"; const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); const appId = "com.t3tools.T3Code"; @@ -207,19 +202,6 @@ it("uses the standard path when KWin's native capability is missing, but not whe bus.kdeError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); await expect(getLinuxCaptureSupport(appId)).rejects.toThrow("Denied"); }); - -it("does not fall through to the picker when KDE needs helper setup", async () => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "KDE"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("KDE capture setup"); - expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); -it("selects Hyprland without probing a different desktop or falling back on missing setup", async () => { - vi.stubEnv("XDG_CURRENT_DESKTOP", "Hyprland"); - expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("hyprland"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Hyprland capture setup"); - expect(bus.calls).toEqual([]); -}); afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); @@ -246,18 +228,6 @@ it.each([ }, ); -it.each([1, 2])( - "reports feedback separately from capture support for extension v%s", - async (version) => { - bus.portalVersion = 2; - bus.extensionVersion = version; - expect(await getLinuxCaptureSupport(appId)).toEqual({ - linuxBackend: "gnome-extension", - linuxFeedbackAvailable: version === 2, - }); - }, -); - it("retains the authenticated connection until the compositor flight has landed", async () => { bus.portalVersion = 2; bus.extensionVersion = 2; @@ -294,19 +264,6 @@ it("keeps capture working with v1 and does not request unavailable effects", asy expect(bus.disconnect).toHaveBeenCalledOnce(); }); -it("can activate without animation, and expires a renderer-abandoned capture", async () => { - vi.useFakeTimers(); - bus.portalVersion = 2; - bus.extensionVersion = 2; - const snapshot = await captureLinuxWindow(appId, { flash: false, animate: false }); - expect(snapshot!.feedback!.animationStarted).toBe(false); - bus.activationError = new Error("No window"); - await expect(snapshot!.feedback!.activate("T3 Code")).rejects.toThrow("No window"); - expect(snapshot!.png).toEqual(png); - await vi.advanceTimersByTimeAsync(15_000); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - it.each([false, true])( "accepts an early Response with a changed request path: %s", async (differentHandle) => { @@ -338,52 +295,6 @@ it.each([1, 2])( }, ); -it("does not downgrade a failed screenshot request", async () => { - bus.screenshotError = new DBusError("org.freedesktop.DBus.Error.AccessDenied", "Denied"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Denied"); - expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - -it.each([false, true])( - "times out and closes requests, ignoring forged responses: %s", - async (wrongSender) => { - vi.useFakeTimers(); - bus.sendResponse = wrongSender; - bus.wrongSender = wrongSender; - const capture = expect(captureLinuxWindow(appId)).rejects.toThrow("timed out"); - await vi.advanceTimersByTimeAsync(120_001); - await capture; - expect(bus.send).toHaveBeenCalledWith(expect.objectContaining({ member: "Close" })); - expect(bus.listenerCount("message")).toBe(0); - expect(bus.disconnect).toHaveBeenCalledOnce(); - }, -); - -it("uses the GNOME extension and preserves its window identity", async () => { - bus.portalVersion = 2; - bus.extensionVersion = 1; - expect(await captureLinuxWindow(appId)).toEqual({ png, window: metadata }); - expect(bus.requestName).toHaveBeenCalledWith(`${appId}.SnapShot`, expect.any(Number)); - expect(bus.calls.some((call) => call.member === "Screenshot")).toBe(false); -}); - -it("fails instead of opening a picker after an extension denial", async () => { - bus.portalVersion = 2; - bus.extensionVersion = 1; - bus.extensionError = new Error("Locked session"); - await expect(captureLinuxWindow(appId)).rejects.toThrow("Locked session"); - expect(bus.disconnect).toHaveBeenCalledOnce(); -}); - -it("allows the picker only when both automatic backends are absent", async () => { - bus.portalVersion = 2; - expect(await captureLinuxWindow(appId)).toBeUndefined(); - expect(bus.calls.some((call) => call.member === "Screenshot" || call.member === "Capture")).toBe( - false, - ); -}); - it("tolerates an older portal without Registry, but not registration denial", async () => { bus.registryError = new DBusError("org.freedesktop.DBus.Error.UnknownMethod", "Old portal"); expect((await getLinuxCaptureSupport(appId)).linuxBackend).toBe("screenshot-portal"); @@ -405,10 +316,3 @@ it("reads only local PNG files without removing the portal file", async () => { await NodeFSP.writeFile(NodePath.join(directory, "image.png"), "not a PNG"); await expect(readPortalPng(bus.uri)).rejects.toThrow("Invalid"); }); - -it("bounds large images without distorting their aspect ratio", () => { - imageSize.width = 4000; - imageSize.height = 2000; - resizeLinuxCapture(png); - expect(resize).toHaveBeenLastCalledWith({ width: 2560, height: 1280, quality: "best" }); -}); diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.ts b/apps/desktop/src/snapShot/LinuxSnapShot.ts index 8603232e7e9f..3d05c5eecceb 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.ts @@ -17,7 +17,7 @@ import { import * as Schema from "effect/Schema"; import { isKdeCaptureSession, type KdeCapturePaths } from "./KdeSnapShot.ts"; import { isGnomeCaptureSession, readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; -export { readPortalPng, resizeLinuxCapture } from "./linuxCaptureSession.ts"; +export { readPortalPng } from "./linuxCaptureSession.ts"; import { isHyprlandCaptureSession, type HyprlandCapturePaths } from "./HyprlandSnapShot.ts"; const PORTAL = "org.freedesktop.portal.Desktop"; @@ -98,7 +98,7 @@ function unavailable(error: unknown): boolean { } /** One connection per operation: portal registration and request ownership stay together. */ -export class LinuxCaptureConnection { +class LinuxCaptureConnection { private readonly bus: MessageBus; private readonly disconnected: Promise; private uniqueName = ""; diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts deleted file mode 100644 index 81889f9f07eb..000000000000 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -const { spawnedPollers } = vi.hoisted(() => ({ - spawnedPollers: [] as Array<{ - command: string; - args: ReadonlyArray; - kill: ReturnType; - emitStderr: (text: string) => void; - emitExit: (code: number) => void; - }>, -})); - -vi.mock("node:child_process", () => ({ - spawn: (command: string, args: ReadonlyArray) => { - const stderrListeners: Array<(chunk: Buffer) => void> = []; - const onceListeners = new Map void>>(); - const record = { - command, - args, - kill: vi.fn(() => true), - emitStderr: (text: string) => { - for (const listener of stderrListeners) listener(Buffer.from(text)); - }, - emitExit: (code: number) => { - for (const listener of onceListeners.get("exit") ?? []) listener(code); - }, - }; - spawnedPollers.push(record); - const child = { - stderr: { - on: (_event: "data", listener: (chunk: Buffer) => void) => { - stderrListeners.push(listener); - return child; - }, - }, - once: (event: string, listener: (value?: unknown) => void) => { - onceListeners.set(event, [...(onceListeners.get(event) ?? []), listener]); - return child; - }, - kill: record.kill, - }; - return child; - }, -})); - -import { startMacModifierPairShortcutProcess } from "./MacModifierPairShortcutProcess.ts"; - -describe("macOS modifier pair poller", () => { - it("resolves on ready, triggers on lines, and kills on stop", async () => { - spawnedPollers.length = 0; - const onTrigger = vi.fn(); - const onFailure = vi.fn(); - const started = startMacModifierPairShortcutProcess("meta", onTrigger, onFailure); - const poller = spawnedPollers[0]!; - expect(poller.command).toBe("/usr/bin/osascript"); - expect(poller.args.slice(-2)).toEqual(["8", "16"]); - - poller.emitStderr("ready\ntrig"); - const stop = await started; - poller.emitStderr("ger\ntrigger\n"); - expect(onTrigger).toHaveBeenCalledTimes(2); - - stop(); - expect(poller.kill).toHaveBeenCalledOnce(); - poller.emitStderr("trigger\n"); - expect(onTrigger).toHaveBeenCalledTimes(2); - poller.emitExit(0); - expect(onFailure).not.toHaveBeenCalled(); - }); - - it("reports an unexpected exit after startup", async () => { - spawnedPollers.length = 0; - const onFailure = vi.fn(); - const started = startMacModifierPairShortcutProcess("shift", () => undefined, onFailure); - const poller = spawnedPollers[0]!; - poller.emitStderr("ready\n"); - await started; - - poller.emitExit(1); - expect(onFailure).toHaveBeenCalledOnce(); - }); - - it("rejects when the poller dies before it is ready", async () => { - spawnedPollers.length = 0; - const started = startMacModifierPairShortcutProcess( - "control", - () => undefined, - () => undefined, - ); - spawnedPollers[0]!.emitExit(1); - await expect(started).rejects.toThrow(/exited with code 1/); - }); -}); diff --git a/apps/desktop/src/snapShot/MacSnapShot.test.ts b/apps/desktop/src/snapShot/MacSnapShot.test.ts deleted file mode 100644 index 43ff082204ae..000000000000 --- a/apps/desktop/src/snapShot/MacSnapShot.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; - -const { createFromBufferMock, execFileMock, readFileMock, writeFileMock } = vi.hoisted(() => ({ - createFromBufferMock: vi.fn(), - execFileMock: vi.fn(), - readFileMock: vi.fn(), - writeFileMock: vi.fn(), -})); - -vi.mock("node:child_process", () => ({ execFile: execFileMock })); -vi.mock("node:fs/promises", () => ({ readFile: readFileMock, writeFile: writeFileMock })); -vi.mock("electron", () => ({ nativeImage: { createFromBuffer: createFromBufferMock } })); - -import { captureMacWindowSnapshot, macSnapShotArguments } from "./MacSnapShot.ts"; - -beforeEach(() => { - execFileMock - .mockReset() - .mockImplementation( - ( - _path: string, - _args: ReadonlyArray, - _options: { readonly timeout: number }, - callback: (error: Error | null) => void, - ) => callback(null), - ); - readFileMock.mockReset(); - writeFileMock.mockReset(); - createFromBufferMock.mockReset().mockReturnValue({ - getSize: () => ({ width: 100, height: 80 }), - isEmpty: () => false, - }); -}); - -it("captures one macOS window directly as a silent shadowless PNG", async () => { - const png = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.from("image"), - ]); - readFileMock.mockResolvedValue(png); - const active = { - id: 42, - title: "Editor", - owner: { name: "Code" }, - } as Parameters[0]; - - const capture = await captureMacWindowSnapshot(active, "/tmp/window.png", { - width: 100, - height: 80, - }); - - assert.deepEqual(execFileMock.mock.calls[0]?.slice(0, 3), [ - "/usr/sbin/screencapture", - macSnapShotArguments(42, "/tmp/window.png"), - { timeout: 15_000 }, - ]); - assert.deepEqual(readFileMock.mock.calls, [["/tmp/window.png"]]); - assert.lengthOf(writeFileMock.mock.calls, 0); - assert.strictEqual(capture.png, png); - assert.deepEqual(capture.source, { name: "Editor" }); -}); - -it("bounds Retina captures without changing their aspect ratio", async () => { - const capturedPng = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.from("retina"), - ]); - const resizedPng = Buffer.from("bounded png"); - const resizeMock = vi.fn().mockReturnValue({ toPNG: () => resizedPng }); - readFileMock.mockResolvedValue(capturedPng); - createFromBufferMock.mockReturnValue({ - getSize: () => ({ width: 2_000, height: 1_200 }), - isEmpty: () => false, - resize: resizeMock, - }); - const active = { - id: 42, - title: "Editor", - owner: { name: "Code" }, - } as Parameters[0]; - - const capture = await captureMacWindowSnapshot(active, "/tmp/window.png", { - width: 1_000, - height: 1_000, - }); - - assert.deepEqual(resizeMock.mock.calls, [[{ width: 1_000, height: 600, quality: "best" }]]); - assert.deepEqual(writeFileMock.mock.calls, [["/tmp/window.png", resizedPng]]); - assert.strictEqual(capture.png, resizedPng); -}); - -it("rejects a successful command that did not produce a PNG", async () => { - readFileMock.mockResolvedValue(Buffer.from("not a png")); - const active = { - id: 42, - title: "", - owner: { name: "Code" }, - } as Parameters[0]; - - await expect( - captureMacWindowSnapshot(active, "/tmp/window.png", { width: 100, height: 80 }), - ).rejects.toThrow("macOS returned an invalid snapshot."); -}); diff --git a/apps/desktop/src/snapShot/MacSnapShot.ts b/apps/desktop/src/snapShot/MacSnapShot.ts index f56c35cb56e0..e987a8fe202e 100644 --- a/apps/desktop/src/snapShot/MacSnapShot.ts +++ b/apps/desktop/src/snapShot/MacSnapShot.ts @@ -15,7 +15,7 @@ export type MacSnapShotSource = { readonly name: string; }; -export function macSnapShotArguments(windowId: number, outputPath: string): string[] { +function macSnapShotArguments(windowId: number, outputPath: string): string[] { return ["-l", String(windowId), "-o", "-x", "-t", "png", outputPath]; } diff --git a/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts b/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts index c0ca5dc87771..ee017fac4086 100644 --- a/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts +++ b/apps/desktop/src/snapShot/NativeCaptureFeedback.test.ts @@ -69,36 +69,6 @@ it("waits for compositor readiness and landing, then waits for cleanup on acknow expect(vi.getTimerCount()).toBe(0); }); -it("doesn't claim a flight when the compositor disables motion; flash still works", async () => { - const starting = startNativeCaptureFeedback("/helper", "/private", options); - event({ event: "ready", animate: false }); - const feedback = (await starting)!; - expect(feedback.animationStarted).toBe(false); - await feedback.animateTo("Draft", { x: 0, y: 0, width: 1, height: 1 }); - expect(commands).toEqual([]); - const complete = feedback.complete(); - child.emit("close"); - await complete; -}); - -it("falls back on startup failure, malformed replies, and timeout without leaving timers", async () => { - const starting = startNativeCaptureFeedback("/helper", "/private", options); - child.stdout.write("not JSON\n"); - expect(commands).toEqual(['{"command":"close"}\n']); - await vi.advanceTimersByTimeAsync(1000); - expect(child.kill).toHaveBeenCalledOnce(); - child.emit("close"); - expect(await starting).toBeUndefined(); - expect(vi.getTimerCount()).toBe(0); -}); - -it("bounds a missing ready receipt and cancels an in-flight animation on compositor failure", async () => { - const timed = startNativeCaptureFeedback("/helper", "/private", options); - await vi.advanceTimersByTimeAsync(2000); - child.emit("close"); - expect(await timed).toBeUndefined(); -}); - it("releases waiters if the overlay closes during flight", async () => { const starting = startNativeCaptureFeedback("/helper", "/private", options); event({ event: "ready", animate: true }); diff --git a/apps/desktop/src/snapShot/NiriSnapShot.test.ts b/apps/desktop/src/snapShot/NiriSnapShot.test.ts index 313d335d3886..6ff7db8e8cc1 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.test.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.test.ts @@ -227,16 +227,6 @@ it("fails promptly when Niri disconnects", async () => { await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("disconnected"); }); -it("bounds requests even when the compositor never answers", async () => { - vi.useFakeTimers(); - const received = Promise.withResolvers(); - handler = async () => received.resolve(); - const result = expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("timed out"); - await received.promise; - await vi.advanceTimersByTimeAsync(5_000); - await result; -}); - it("does not use stale, relative, or sandboxed Niri sockets", () => { expect(niriSocketPath()).toBe(socketPath); expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "GNOME", NIRI_SOCKET: socketPath })).toBeUndefined(); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts deleted file mode 100644 index acd9a29b3220..000000000000 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Private D-Bus integration fixture, never the user's session bus. -import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; -import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; -import { expect, it, vi } from "vite-plus/test"; -import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts"; - -it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( - "registers, rebinds saved keys, receives activations, and replaces sessions over real D-Bus", - async () => { - const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-shortcut-dbus-")); - let daemon: NodeChildProcess.ChildProcess | undefined; - let server: MessageBus | undefined; - const clients: PortalCaptureShortcut[] = []; - try { - vi.stubEnv("FLATPAK_ID", ""); - vi.stubEnv("SNAP", ""); - daemon = NodeChildProcess.spawn( - "dbus-daemon", - [ - "--session", - "--nofork", - "--nopidfile", - "--print-address", - `--address=unix:path=${NodePath.join(dir, "bus")}`, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const lines = NodeReadline.createInterface({ input: daemon.stdout! }); - const [address] = await Promise.race([ - NodeEvents.EventEmitter.once(lines, "line"), - NodeEvents.EventEmitter.once(daemon, "exit").then(() => { - throw new Error("Private bus failed to start"); - }), - ]); - lines.close(); - const connect = () => sessionBus({ busAddress: String(address) }); - server = connect(); - server.on("error", () => undefined); - await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE); - const root = "/org/freedesktop/portal/desktop"; - const iface = "org.freedesktop.portal.GlobalShortcuts"; - const sessions = new Map(); - const bindings = new Map(); - const identities = new Set(); - const closed = new Set(); - const closeWaiters = new Map void>(); - let bindCount = 0; - server.addMethodHandler((message: Message) => { - if (message.member === "Register") { - expect(message.body[0]).toBe("com.t3tools.T3Code"); - identities.add(message.sender); - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Get") { - server!.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)])); - } else if (message.member === "Close") { - closed.add(message.path); - closeWaiters.get(message.path)?.(); - } else if (["CreateSession", "BindShortcuts"].includes(message.member)) { - expect(identities.has(message.sender)).toBe(true); - const options = message.body.at(-1) as Record>; - const sender = message.sender.slice(1).replaceAll(".", "_"); - const handle = `${root}/request/${sender}/${options.handle_token!.value}`; - let results: Record>; - if (message.member === "CreateSession") { - const session = `${root}/session/${sender}/${options.session_handle_token!.value}`; - sessions.set(message.sender, session); - results = { session_handle: new Variant("s", session) }; - } else { - bindCount++; - const shortcuts = message.body[1] as Array< - [string, { preferred_trigger: Variant }] - >; - expect(shortcuts).toHaveLength(1); - const [id, properties] = shortcuts[0]!; - expect(properties.preferred_trigger.signature).toBe("s"); - bindings.set(message.sender, id); - const bound = [ - [ - id, - { - description: new Variant("s", "Capture a window"), - trigger_description: new Variant("s", properties.preferred_trigger.value), - }, - ], - ]; - results = { shortcuts: new Variant("a(sa{sv})", bound) }; - } - const response = Message.newSignal( - handle, - "org.freedesktop.portal.Request", - "Response", - "ua{sv}", - [0, results], - ); - response.destination = message.sender; - server!.send(response); - server!.send(Message.newMethodReturn(message, "o", [handle])); - } else return false; - return true; - }); - - const shortcut = { - key: "2", - modKey: true, - ctrlKey: false, - shiftKey: true, - altKey: false, - metaKey: false, - }; - const start = (key = "2") => { - const received = Promise.withResolvers(); - const capture = vi.fn(() => received.resolve()); - const client = new PortalCaptureShortcut( - "com.t3tools.T3Code", - { ...shortcut, key }, - capture, - () => {}, - connect(), - ); - clients.push(client); - return { client, capture, received: received.promise }; - }; - const activate = (sender: string) => { - const signal = Message.newSignal(root, iface, "Activated", "osta{sv}", [ - sessions.get(sender), - bindings.get(sender), - 123, - {}, - ]); - signal.destination = sender; - server!.send(signal); - }; - const first = start(); - await first.client.ready; - expect(first.client.state.shortcutRegistered).toBe(true); - const firstSender = [...bindings.keys()][0]!; - activate(firstSender); - await first.received; - expect(first.capture).toHaveBeenCalledOnce(); - const sessionClosed = new Promise((resolve) => - closeWaiters.set(sessions.get(firstSender)!, resolve), - ); - first.client.close(); - await sessionClosed; - expect(closed.has(sessions.get(firstSender)!)).toBe(true); - - const restoredClient = start(); - await restoredClient.client.ready; - expect(restoredClient.client.state.shortcutRegistered).toBe(true); - expect(bindCount).toBe(2); - expect([...bindings.values()][1]).toBe(bindings.get(firstSender)); - const next = start("8"); - await next.client.ready; - expect(next.client.state.shortcutLabel).toBe("CTRL+SHIFT+8"); - expect(bindCount).toBe(3); - const lastSender = [...bindings.keys()].at(-1)!; - expect(bindings.get(lastSender)).not.toBe(bindings.get(firstSender)); - activate(lastSender); - await next.received; - expect(next.capture).toHaveBeenCalledOnce(); - } finally { - clients.forEach((client) => client.close()); - server?.disconnect(); - if (daemon && daemon.exitCode === null) { - const exited = NodeEvents.EventEmitter.once(daemon, "exit"); - daemon.kill(); - await exited; - } - vi.unstubAllEnvs(); - await NodeFSP.rm(dir, { recursive: true, force: true }); - } - }, -); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts index eb20a4a041ee..35c007e35dc0 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts @@ -158,24 +158,6 @@ it("accepts Hyprland's action-only binding without claiming the keys are reserve expect(client.state.shortcutActionRegistered).toBe(false); }); -it("keeps the Hyprland action ID stable regardless of a saved key chord", async () => { - const first = start(new FakeBus(), chord, true); - const second = start(new FakeBus(), { ...chord, key: "7" }, true); - await Promise.all([first.client.ready, second.client.ready]); - expect(first.bus.boundId).toBe(second.bus.boundId); -}); - -it("does not accept an empty trigger as approval on other desktops", async () => { - const bus = new FakeBus(); - bus.actualLabel = ""; - const { client, capture } = start(bus); - await client.ready; - expect(client.state.shortcutRegistered).toBe(false); - expect(client.state.shortcutActionRegistered).not.toBe(true); - bus.activate(); - expect(capture).not.toHaveBeenCalled(); -}); - it.each([ ["2", "CTRL+SHIFT+2"], ["f12", "CTRL+SHIFT+F12"], @@ -309,23 +291,6 @@ it("treats a remembered empty binding as unassigned, then observes desktop-side expect(capture).toHaveBeenCalledOnce(); }); -it("guides users to manual desktop settings when the portal cannot open them", async () => { - const bus = new FakeBus(); - bus.version = 1; - bus.bindStatus = 1; - const { client } = start(bus); - await client.ready; - expect(client.hasSession).toBe(true); - expect(client.state.shortcutCanRetry).toBe(false); - expect(client.state.shortcutMessage).toBe( - "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", - ); - await expect(client.configure()).rejects.toThrow("Open your desktop's shortcut settings"); - expect(bus.calls.some((message) => message.member === "ConfigureShortcuts")).toBe(false); - client.close(); - expect(client.hasSession).toBe(false); -}); - it("captures on older portals without offering an unsupported permission dialog", async () => { const bus = new FakeBus(); bus.version = 1; @@ -361,21 +326,6 @@ it("closes an outstanding consent request and ignores its late response", async expect(bus.disconnect).toHaveBeenCalledOnce(); }); -it("bounds unanswered consent and cleans up the pending request", async () => { - vi.useFakeTimers(); - const bus = new FakeBus(); - bus.autoBind = false; - const binding = NodeEvents.EventEmitter.once(bus, "bind"); - const { client } = start(bus); - await binding; - await vi.advanceTimersByTimeAsync(120_000); - await client.ready; - expect(client.state.shortcutPending).toBe(false); - expect(client.state.shortcutMessage).toContain("timed out"); - expect(bus.disconnect).toHaveBeenCalledOnce(); - expect(vi.getTimerCount()).toBe(0); -}); - it("handles a disappearing portal without keeping a false registered state", async () => { const { bus, client } = start(); await client.ready; diff --git a/apps/desktop/src/snapShot/RegionSnapShot.test.ts b/apps/desktop/src/snapShot/RegionSnapShot.test.ts index 0d6381bb3f28..24b233b465d5 100644 --- a/apps/desktop/src/snapShot/RegionSnapShot.test.ts +++ b/apps/desktop/src/snapShot/RegionSnapShot.test.ts @@ -1,7 +1,6 @@ import type * as Electron from "electron"; import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; -import type { ActiveWindow } from "./ActiveWindow.ts"; import type { RegionSnapShotChild, RegionSnapShotProcess } from "./RegionSnapShot.ts"; const { createFromBufferMock, resizeMock } = vi.hoisted(() => ({ @@ -13,59 +12,13 @@ vi.mock("electron", () => ({ nativeImage: { createFromBuffer: createFromBufferMock }, })); -import { - captureRegionWindowSnapshot, - makeRegionSnapShotPool, - startRegionSnapShotProcess, -} from "./RegionSnapShot.ts"; +import { makeRegionSnapShotPool, startRegionSnapShotProcess } from "./RegionSnapShot.ts"; beforeEach(() => { vi.clearAllMocks(); createFromBufferMock.mockReturnValue({ resize: resizeMock }); }); -const active = { - title: "Editor", - owner: { name: "Editor", processId: 123 }, - bounds: { x: 10, y: 20, width: 800, height: 600 }, -} as ActiveWindow; - -it("returns a small capture's PNG untouched", async () => { - const png = Buffer.from([1, 2, 3]); - const capture = vi.fn(async () => ({ width: 400, height: 300, png })); - const region = { x: 5, y: 10, width: 400, height: 300 }; - - const result = await captureRegionWindowSnapshot({ capture }, active, region, region); - - assert.deepEqual(capture.mock.calls, [[region]]); - assert.strictEqual(result.png, png); - assert.deepEqual(result.source, { name: "Editor" }); - assert.lengthOf(createFromBufferMock.mock.calls, 0); -}); - -it.each([ - { width: 600, height: 400, expected: { width: 240, height: 160 } }, - { width: 400, height: 600, expected: { width: 107, height: 160 } }, - { width: 600, height: 1, expected: { width: 256, height: 1 } }, -])( - "bounds $width x $height physical pixels before encoding", - async ({ width, height, expected }) => { - const png = Buffer.from([1, 2, 3]); - const resizedPng = Buffer.from([4, 5, 6]); - const capture = vi.fn(async () => ({ width, height, png })); - resizeMock.mockReturnValue({ toPNG: () => resizedPng }); - - const result = await captureRegionWindowSnapshot({ capture }, active, active.bounds, { - width: 256, - height: 160, - }); - - assert.deepEqual(createFromBufferMock.mock.calls, [[png]]); - assert.deepEqual(resizeMock.mock.calls, [[{ ...expected, quality: "best" }]]); - assert.strictEqual(result.png, resizedPng); - }, -); - function fakeChild() { const messageListeners: Array<(message: unknown) => void> = []; const exitListeners: Array<() => void> = []; @@ -92,37 +45,6 @@ function fakeChild() { const region = { x: 0, y: 0, width: 2, height: 1 }; -it("resolves the decoded capture once the child is ready and replies", async () => { - const fake = fakeChild(); - const fork = vi.fn(() => fake.child); - const png = Buffer.from([9, 8, 7]); - const child = startRegionSnapShotProcess("worker.cjs", fork); - assert.deepEqual(fork.mock.calls, [["worker.cjs"]]); - assert.isTrue(child.alive); - - const capture = child.capture(region); - await Promise.resolve(); - assert.lengthOf(fake.send.mock.calls, 0); - fake.emit("ready"); - await vi.waitFor(() => assert.deepEqual(fake.send.mock.calls, [[{ region }]])); - fake.emit({ type: "result", width: 2, height: 1, png: png.toString("base64") }); - - assert.deepEqual(await capture, { width: 2, height: 1, png }); - assert.isFalse(child.alive); - assert.lengthOf(fake.kill.mock.calls, 1); -}); - -it("rejects with the child's error message", async () => { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const capture = child.capture(region); - fake.emit("ready"); - fake.emit({ type: "error", message: "Screen capture failed" }); - - await expect(capture).rejects.toThrow("Screen capture failed"); - assert.isFalse(child.alive); -}); - it("rejects when the child exits before replying", async () => { const fake = fakeChild(); const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); @@ -133,19 +55,6 @@ it("rejects when the child exits before replying", async () => { await expect(capture).rejects.toThrow(/helper exited/); }); -it("serves a single capture per child", async () => { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const first = child.capture(region); - fake.emit("ready"); - await vi.waitFor(() => assert.lengthOf(fake.send.mock.calls, 1)); - fake.emit({ type: "result", width: 1, height: 1, png: Buffer.from([1]).toString("base64") }); - await first; - - await expect(child.capture(region)).rejects.toThrow(/already used/); - assert.lengthOf(fake.send.mock.calls, 1); -}); - it("cancels a pending capture on close and kills the child", async () => { const fake = fakeChild(); const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); @@ -159,31 +68,6 @@ it("cancels a pending capture on close and kills the child", async () => { assert.lengthOf(fake.send.mock.calls, 0); }); -it("reports an unavailable helper when the child cannot be forked", async () => { - const child = startRegionSnapShotProcess("worker.cjs", () => { - throw new Error("fork failed"); - }); - - assert.isFalse(child.alive); - await expect(child.capture(region)).rejects.toThrow(/unavailable/); -}); - -it("times out a stalled capture", async () => { - vi.useFakeTimers(); - try { - const fake = fakeChild(); - const child = startRegionSnapShotProcess("worker.cjs", () => fake.child); - const failure = expect(child.capture(region)).rejects.toThrow(/timed out/); - fake.emit("ready"); - await vi.advanceTimersByTimeAsync(15_000); - - await failure; - assert.lengthOf(fake.kill.mock.calls, 1); - } finally { - vi.useRealTimers(); - } -}); - function fakeProcess() { const result = Promise.withResolvers<{ width: number; height: number; png: Buffer }>(); void result.promise.catch(() => undefined); @@ -221,41 +105,6 @@ function fakePool() { return { pool: makeRegionSnapShotPool("worker.cjs", start), processes, start }; } -it("warms one standby child and keeps it while alive", () => { - const { pool, start } = fakePool(); - - pool.warm(); - pool.warm(); - - assert.deepEqual(start.mock.calls, [["worker.cjs"]]); -}); - -it("captures with the standby child and warms a replacement afterwards", async () => { - const { pool, processes, start } = fakePool(); - pool.warm(); - const shot = { width: 1, height: 1, png: Buffer.from([1]) }; - - const capture = pool.capture(region); - assert.deepEqual(processes[0]!.capture.mock.calls, [[region]]); - assert.lengthOf(start.mock.calls, 1); - processes[0]!.resolve(shot); - - assert.deepEqual(await capture, shot); - assert.lengthOf(start.mock.calls, 2); - assert.lengthOf(processes[1]!.capture.mock.calls, 0); -}); - -it("starts a child on demand when no standby is warm", async () => { - const { pool, processes, start } = fakePool(); - const shot = { width: 1, height: 1, png: Buffer.from([1]) }; - - const capture = pool.capture(region); - assert.lengthOf(start.mock.calls, 1); - processes[0]!.resolve(shot); - - assert.deepEqual(await capture, shot); -}); - it("rejects a second capture while one is in flight", async () => { const { pool, processes } = fakePool(); const first = pool.capture(region); @@ -267,17 +116,6 @@ it("rejects a second capture while one is in flight", async () => { await first; }); -it("cools the standby child", () => { - const { pool, processes, start } = fakePool(); - pool.warm(); - - pool.cool(); - - assert.lengthOf(processes[0]!.close.mock.calls, 1); - pool.warm(); - assert.lengthOf(start.mock.calls, 2); -}); - it("closes the standby and the in-flight child", async () => { const { pool, processes } = fakePool(); pool.warm(); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts index 6edfa017584a..e4e80c6c4db6 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.test.ts @@ -1,6 +1,5 @@ import { assert, beforeEach, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; -import * as NodeProcess from "node:process"; import { vi } from "vite-plus/test"; const forkMock = vi.hoisted(() => @@ -62,42 +61,6 @@ it("returns immediately when accessibility crashes before the screenshot is read assert.isUndefined(await read.result); }); -it("returns accessibility extracted by the helper", async () => { - const process = startSnapShotAccessibilityProcess("accessibility.cjs"); - const read = process.read(request); - const context = { accessibleText: "Meeting controls" }; - worker.emit("message", "ready"); - worker.emit("message", "started"); - worker.emit("message", { type: "result", context }); - - await read.started; - assert.deepEqual(await read.result, context); - assert.strictEqual(forkMock.mock.calls[0]?.[2]?.env?.ELECTRON_RUN_AS_NODE, "1"); - assert.strictEqual(forkMock.mock.calls[0]?.[2]?.execPath, NodeProcess.execPath); -}); - -it("hands a warm helper to the capture and prewarms its replacement", async () => { - const warmWorker = makeWorker(); - const replacementWorker = makeWorker(); - forkMock.mockReset().mockReturnValueOnce(warmWorker).mockReturnValueOnce(replacementWorker); - const pool = makeSnapShotAccessibilityProcessPool("accessibility.cjs"); - - pool.warm(); - warmWorker.emit("message", "ready"); - const read = pool.read(request); - - assert.lengthOf(forkMock.mock.calls, 2); - assert.deepEqual(warmWorker.send.mock.calls[0]?.[0], request); - assert.lengthOf(replacementWorker.send.mock.calls, 0); - - warmWorker.emit("message", "started"); - warmWorker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); - assert.deepEqual(await read.result, { accessibleText: "Ready" }); - - pool.close(); - assert.lengthOf(replacementWorker.kill.mock.calls, 1); -}); - it("replaces a warm helper that exits before capture", async () => { const exitedWorker = makeWorker(); const captureWorker = makeWorker(); @@ -121,21 +84,3 @@ it("replaces a warm helper that exits before capture", async () => { assert.deepEqual(captureWorker.send.mock.calls[0]?.[0], request); pool.close(); }); - -it("allows cold startup to exceed the request-start budget", async () => { - vi.useFakeTimers(); - const process = startSnapShotAccessibilityProcess("accessibility.cjs"); - try { - const read = process.read(request); - await vi.advanceTimersByTimeAsync(1_500); - assert.lengthOf(worker.kill.mock.calls, 0); - worker.emit("message", "ready"); - assert.deepEqual(worker.send.mock.calls[0]?.[0], request); - worker.emit("message", "started"); - worker.emit("message", { type: "result", context: { accessibleText: "Ready" } }); - assert.deepEqual(await read.result, { accessibleText: "Ready" }); - } finally { - process.close(); - vi.useRealTimers(); - } -}); diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 6a25cd5048b4..a8ca237009bb 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -26,7 +26,7 @@ export type SnapShotAnimationDestination = { readonly details?: SnapShotAnimationDetails | undefined; }; -export function snapShotAnimationDurationMs( +function snapShotAnimationDurationMs( source: Electron.Rectangle, target: Electron.Rectangle, ): number { @@ -64,7 +64,7 @@ type SnapShotTransitionOptions = { | undefined; }; -export function snapShotAnimationOverlayBounds( +function snapShotAnimationOverlayBounds( displays: ReadonlyArray>, ): Electron.Rectangle { const firstDisplay = displays[0]; @@ -175,7 +175,7 @@ window.prepareCaptureTransition=async destination=>{ const inner={width:Math.max(1,target.width-borderWidth*2),height:Math.max(1,target.height-borderWidth*2)}; const sourceCenter={x:source.x+source.width/2,y:source.y+source.height/2},targetCenter={x:target.x+target.width/2,y:target.y+target.height/2}; const initial="translate3d("+(sourceCenter.x-targetCenter.x)+"px,"+(sourceCenter.y-targetCenter.y)+"px,0) scale("+(source.width/target.width)+","+(source.height/target.height)+")"; - const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),fit=Math.min(inner.width/imageWidth,inner.height/imageHeight); + const imageWidth=Math.max(1,snapshot.naturalWidth||source.width),imageHeight=Math.max(1,snapshot.naturalHeight||source.height),cover=Math.max(inner.width/imageWidth,inner.height/imageHeight); Object.assign(card.style,{left:target.x+"px",top:target.y+"px",width:target.width+"px",height:target.height+"px",borderRadius:destination.cornerRadius+"px",backgroundColor:destination.backgroundColor,boxShadow:"none"}); Object.assign(content.style,{inset:borderWidth+"px",borderRadius:Math.max(0,destination.cornerRadius-borderWidth)+"px"}); Object.assign(border.style,{borderWidth:borderWidth+"px",borderColor:destination.borderColor}); @@ -185,7 +185,7 @@ window.prepareCaptureTransition=async destination=>{ const animations=[ card.animate([{transform:initial},{transform:"translate3d(0,0,0) scale(1)"}],options), content.animate([{transform:"scale("+(target.width/inner.width)+","+(target.height/inner.height)+")"},{transform:"scale(1)"}],options), - snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*fit)/inner.width)+","+((imageHeight*fit)/inner.height)+")"}],options), + snapshot.animate([{transform:"scale(1)"},{transform:"scale("+((imageWidth*cover)/inner.width)+","+((imageHeight*cover)/inner.height)+")"}],options), border.animate([{opacity:0},{opacity:1}],options), details.animate([{opacity:0},{opacity:1}],{delay:Math.max(0,destination.durationMs-125),duration:125,fill:"both",easing:"ease-in"}) ]; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts index 4a319d7ac36a..aa56de090897 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.test.ts @@ -33,29 +33,3 @@ it("disables Chromium window animation only while showing the capture overlay", expect(flagsDuringShow).toEqual([["unrelated-switch", animationSwitch]]); expect([...switches]).toEqual(["unrelated-switch"]); }); - -it("preserves an animation switch supplied before capture", () => { - switches.add(animationSwitch); - const flagsDuringShow: string[][] = []; - - showWindowsCaptureOverlay(overlay(() => flagsDuringShow.push([...switches]))); - - expect(flagsDuringShow).toEqual([["unrelated-switch", animationSwitch]]); - expect([...switches]).toEqual(["unrelated-switch", animationSwitch]); -}); - -it.each([false, true])( - "restores the original switch state when showing throws (preexisting: %s)", - (preexisting) => { - if (preexisting) switches.add(animationSwitch); - const original = [...switches]; - const failure = new Error("Overlay was destroyed"); - const window = overlay(() => { - expect(switches.has(animationSwitch)).toBe(true); - throw failure; - }); - - expect(() => showWindowsCaptureOverlay(window)).toThrow(failure); - expect([...switches]).toEqual(original); - }, -); diff --git a/apps/desktop/src/snapShot/captureConfigEdit.test.ts b/apps/desktop/src/snapShot/captureConfigEdit.test.ts index 6d57d35ee8b6..9db407be961d 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.test.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.test.ts @@ -39,14 +39,6 @@ describe("Niri capture config edits", () => { `\uFEFFbinds {\r\n ${binding}\r\n}\r\n`, ); }); - it("preserves Unicode spacing around nodes and line continuations", () => { - const spaces = - "\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000"; - const before = `binds${spaces}\\${spaces}\n{\n}\n`; - expect(editCaptureConfig(before, "niri", app, "install").after).toBe( - before.replace("}\n", ` ${binding}\n}\n`), - ); - }); it("does not duplicate an existing binding, including a custom chord", () => { const before = `binds {\n ${binding.replace("Ctrl+Shift+2", "Alt+Ctrl+Y")}\n}\n`; const result = editCaptureConfig(before, "niri", app, "install"); @@ -68,11 +60,6 @@ describe("Niri capture config edits", () => { expect(niriConfigConflict(before, app, "Ctrl+Shift+2")).toBe(true); expect(() => editCaptureConfig(before, "niri", app, "install")).toThrow("already used"); }); - it("does not count a slash-dash-disabled key as occupied", () => { - expect(niriConfigConflict("binds { /- Ctrl+Shift+2 { quit; }; }", app, "Ctrl+Shift+2")).toBe( - false, - ); - }); it.each(["binds {", "binds {}\nbinds {}", "/* unfinished", 'spawn "unfinished'])( "fails safely for malformed structure: %s", (source) => { @@ -115,10 +102,6 @@ describe("Hyprland capture config edits", () => { editCaptureConfig("bind = SHIFT CTRL, 2, exec, kitty\n", "hyprland", app, "install"), ).toThrow("already used"); }); - it("leaves commented bindings alone", () => { - const before = "# bind = CTRL SHIFT, 2, global, com.t3tools.T3Code:capture-window\n"; - expect(editCaptureConfig(before, "hyprland", app, "remove").after).toBe(before); - }); it.each([ "return {}", '--[[\nhl.bind("CTRL + SHIFT + 2", hl.dsp.global("com.t3tools.T3Code:capture-window"))\n]]', diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 8f57ad55563c..26ae8fbf9ebe 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -1,58 +1,19 @@ import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { accessibleWindowElementTree, accessibleWindowText, - capturedImageBounds, compactAccessibilityTree, findAccessibleWindow, findCaptureSource, hideAndWaitForBlur, isWaylandSession, - snapShotShortcutRegistrationFailureMessage, - snapShotShortcutSystemConflict, - toElectronAccelerator, } from "./snapShot.ts"; -import { DesktopSnapShotError } from "./DesktopSnapShot.ts"; - -describe("window capture errors", () => { - it.each([ - ["unsupported", "SnapShots are not supported here."], - ["disabled", "Enable SnapShots in Settings first."], - ["no-window-selected", "No window was selected."], - ["window-unavailable", "The active window is not available for capture."], - ["capture", "Could not capture the active window."], - ] as const)("keeps %s failures user-facing", (operation, message) => { - expect(new DesktopSnapShotError({ operation }).message).toBe(message); - }); -}); describe("accessibleWindowText", () => { - it("keeps unique names and values in tree order", () => { - expect( - accessibleWindowText( - { - name: "Settings", - children: [ - { - name: "General", - children: [], - }, - { - name: "Name", - value: "Bilal", - children: [], - }, - ], - }, - 100, - ), - ).toBe("Settings\nGeneral\nName\nBilal"); - }); - it("caps large text without splitting a surrogate pair", () => { expect( accessibleWindowText( @@ -263,23 +224,6 @@ describe("accessibleWindowElementTree", () => { expect(tree?.root.children.length).toBeLessThan(10); expect(JSON.stringify(tree).length).toBeLessThanOrEqual(32_000); }); - - it.each(["name", "value", "description"] as const)( - "reports clipped %s text in partial trees", - async (field) => { - const tree = await accessibleWindowElementTree( - { - role: "window", - children: async () => [{ role: "text_area", [field]: "x".repeat(8_001) }], - }, - { x: 0, y: 0, width: 800, height: 600 }, - { width: 800, height: 600 }, - ); - - expect(tree?.truncated).toBe(true); - expect(tree?.root.children[0]?.[field]?.length).toBeLessThan(8_001); - }, - ); }); describe("compactAccessibilityTree", () => { @@ -304,28 +248,6 @@ describe("compactAccessibilityTree", () => { }); }); -describe("capturedImageBounds", () => { - it("clips locations to the attached image", () => { - expect( - capturedImageBounds( - { x: 50, y: 150, width: 200, height: 200 }, - { x: 100, y: 200, width: 800, height: 600 }, - { width: 1_600, height: 1_200 }, - ), - ).toEqual({ x: 0, y: 0, width: 300, height: 300 }); - }); - - it("returns null when an accessibility provider omits a location", () => { - expect( - capturedImageBounds( - null, - { x: 0, y: 0, width: 800, height: 600 }, - { width: 800, height: 600 }, - ), - ).toBeNull(); - }); -}); - describe("findAccessibleWindow", () => { const captured = { title: "Editor", @@ -393,17 +315,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured)).toBe(windows[1]); }); - it("uses the matched source title when macOS omits the active title", () => { - const windows = [{ name: "Editor", bounds: captured.bounds }]; - expect( - findAccessibleWindow(windows, { - ...captured, - title: "", - sourceTitle: "Editor", - }), - ).toBe(windows[0]); - }); - it("uses the captured source title when the active title changed", () => { const windows = [{ name: "Editor — saved", bounds: captured.bounds }]; @@ -434,15 +345,6 @@ describe("findAccessibleWindow", () => { ).toBeUndefined(); }); - it("does not use a title match when the bounds differ", () => { - expect( - findAccessibleWindow( - [{ name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 600 } }], - captured, - ), - ).toBeUndefined(); - }); - it("matches a Wayland window whose accessibility provider omits its screen position", () => { const captured = { title: "hello world (Draft) - Text Editor", @@ -492,24 +394,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured, "wayland")).toBe(windows[1]); }); - it.each([ - { name: "Private", bounds: { x: 0, y: 0, width: 800, height: 600 } }, - { name: "Editor", bounds: { x: 0, y: 0, width: 400, height: 600 } }, - { name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 300 } }, - { name: "Editor", bounds: null }, - ])("rejects an unverified Wayland window: %j", (window) => { - expect(findAccessibleWindow([window], captured, "wayland")).toBeUndefined(); - }); - - it("rejects ambiguous Wayland windows even when one reports the captured screen position", () => { - const windows = [ - { name: "Editor", bounds: captured.bounds }, - { name: "Editor", bounds: { x: 0, y: 0, width: 800, height: 600 } }, - ]; - - expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); - }); - it("does not match an untitled Wayland window by size alone", () => { const windows = [{ name: "", bounds: captured.bounds }]; @@ -525,17 +409,6 @@ describe("findAccessibleWindow", () => { expect(findAccessibleWindow(windows, captured, "wayland")).toBeUndefined(); }); - it("rejects ambiguous PID-scoped untitled windows even when bounds match", () => { - const windows = [ - { name: null, bounds: captured.bounds }, - { name: "", bounds: { ...captured.bounds, x: 0, y: 0 } }, - ]; - - expect( - findAccessibleWindow(windows, captured, "wayland", { allowUntitledUniqueBounds: true }), - ).toBeUndefined(); - }); - it("does not fall back to a differently titled PID-scoped window with matching bounds", () => { const windows = [{ name: "Preferences", bounds: captured.bounds }]; @@ -555,17 +428,6 @@ describe("findAccessibleWindow", () => { ).toBeUndefined(); }); - it.each(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])( - "ignores a leading Wayland title spinner frame %s", - (frame) => { - const windows = [{ name: `${frame} t3code`, bounds: captured.bounds }]; - - expect(findAccessibleWindow(windows, { ...captured, title: "⠋ t3code" }, "wayland")).toBe( - windows[0], - ); - }, - ); - it.each([ ["⠋ t3code", "t3code"], ["t3code", "⠙ t3code"], @@ -586,36 +448,6 @@ describe("findAccessibleWindow", () => { findAccessibleWindow([{ name, bounds: captured.bounds }], { ...captured, title }, "wayland"), ).toBeUndefined(); }); - - it("rejects matching spinners when the window sizes differ", () => { - expect( - findAccessibleWindow( - [{ name: "⠙ t3code", bounds: { ...captured.bounds, width: 400 } }], - { ...captured, title: "⠋ t3code" }, - "wayland", - ), - ).toBeUndefined(); - }); - - it("rejects ambiguous normalized titles even if one matches the captured spinner exactly", () => { - const windows = [ - { name: "⠋ t3code", bounds: captured.bounds }, - { name: "⠙ t3code", bounds: captured.bounds }, - ]; - - expect( - findAccessibleWindow(windows, { ...captured, title: "⠋ t3code" }, "wayland"), - ).toBeUndefined(); - }); - - it("keeps exact title matching outside Wayland", () => { - expect( - findAccessibleWindow([{ name: "⠙ t3code", bounds: captured.bounds }], { - ...captured, - title: "⠋ t3code", - }), - ).toBeUndefined(); - }); }); describe("hideAndWaitForBlur", () => { @@ -638,67 +470,6 @@ describe("hideAndWaitForBlur", () => { await hidden; expect(settled).toBe(true); }); - - it("rejects when the hidden window never blurs", async () => { - vi.useFakeTimers(); - try { - let rejected = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: () => undefined, - removeListener: () => undefined, - }).catch(() => { - rejected = true; - }); - - await vi.advanceTimersByTimeAsync(1_000); - expect(rejected).toBe(true); - await hidden; - } finally { - vi.useRealTimers(); - } - }); -}); - -describe("toElectronAccelerator", () => { - it("converts the default portable shortcut", () => { - expect( - toElectronAccelerator({ - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }), - ).toBe("CommandOrControl+Shift+2"); - }); - - it("maps the portable meta key to Super", () => { - expect( - toElectronAccelerator({ - key: "k", - metaKey: true, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: false, - }), - ).toBe("Super+K"); - }); - - it("normalizes Electron key names", () => { - expect( - toElectronAccelerator({ - key: "ArrowUp", - metaKey: false, - ctrlKey: true, - shiftKey: false, - altKey: true, - modKey: false, - }), - ).toBe("Control+Alt+Up"); - }); }); describe("findCaptureSource", () => { @@ -742,15 +513,6 @@ describe("findCaptureSource", () => { }); describe("isWaylandSession", () => { - it.each([ - ["linux", { XDG_SESSION_TYPE: "wayland" }, true], - ["linux", { WAYLAND_DISPLAY: "wayland-0" }, true], - ["linux", { XDG_SESSION_TYPE: "x11" }, false], - ["darwin", { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, false], - ] as const)("detects %s session %o as portal=%s", (platform, environment, expected) => { - expect(isWaylandSession(platform, environment)).toBe(expected); - }); - effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", () => @@ -795,74 +557,3 @@ describe("isWaylandSession", () => { }), ); }); - -describe("snapShotShortcutRegistrationFailureMessage", () => { - it("distinguishes a modifier listener failure from a reserved key chord", () => { - expect( - snapShotShortcutRegistrationFailureMessage({ kind: "both-shift-keys" }, "darwin"), - ).toMatch(/Shift \+ Shift is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { kind: "modifier-pair", modifier: "meta" }, - "darwin", - ), - ).toMatch(/Command \+ Command is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { kind: "modifier-pair", modifier: "meta" }, - "linux", - ), - ).toMatch(/Super \+ Super is not available/); - expect( - snapShotShortcutRegistrationFailureMessage( - { - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }, - "darwin", - ), - ).toMatch(/already used/); - }); -}); - -describe("snapShotShortcutSystemConflict", () => { - it("blocks shortcuts that would break typing or common app actions", () => { - expect( - snapShotShortcutSystemConflict({ - key: "s", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: false, - }), - ).toMatch(/typing/); - expect( - snapShotShortcutSystemConflict({ - key: "c", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }), - ).toMatch(/Copy/); - }); - - it("allows a specific multi-modifier shortcut", () => { - expect( - snapShotShortcutSystemConflict({ - key: "2", - metaKey: false, - ctrlKey: false, - shiftKey: true, - altKey: false, - modKey: true, - }), - ).toBeNull(); - }); -}); diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 6da59dcdc5ac..958e017299e0 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -168,7 +168,7 @@ export function boundedSnapShotString( return candidate.slice(0, end).trimEnd(); } -export function capturedImageBounds( +function capturedImageBounds( bounds: WindowBounds | null | undefined, sourceBounds: WindowBounds, imageSize: CapturedImageSize, diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts deleted file mode 100644 index adb0a12e8f06..000000000000 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; - -import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; - -function makeTempHomeDir() { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); - }); -} - -describe("sshEnvironment", () => { - it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => { - const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({ - requestId: "prompt-1", - destination: "devbox", - operation: "send-prompt-request", - cause: new Error("renderer send failed"), - }); - - assert.equal(cause.message, "Failed to present SSH password prompt for devbox."); - assert.equal( - DesktopSshEnvironment.toSshPasswordPromptError(cause).message, - "T3 Code window is not available for SSH authentication.", - ); - }); - - it("treats password prompt timeouts as cancellable authentication prompts", () => { - assert.equal( - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - true, - ); - }); - - it.effect("wires desktop host discovery through the ssh package runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDir = yield* makeTempHomeDir(); - const sshDir = path.join(homeDir, ".ssh"); - yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); - yield* fs.writeFileString( - path.join(sshDir, "config"), - ["Include config.d/*.conf", "Host devbox", " HostName devbox.example.com", ""].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "config.d", "team.conf"), - [ - "Host staging", - " HostName staging.example.com", - "Host *", - " ServerAliveInterval 30", - "", - ].join("\n"), - ); - yield* fs.writeFileString( - path.join(sshDir, "known_hosts"), - [ - "known.example.com ssh-ed25519 AAAA", - "|1|hashed|entry ssh-ed25519 AAAA", - "[bastion.example.com]:2222 ssh-ed25519 AAAA", - "", - ].join("\n"), - ); - - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); - assert.deepEqual(hosts, [ - { - alias: "bastion.example.com", - hostname: "bastion.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "devbox", - hostname: "devbox.example.com", - username: null, - port: null, - source: "ssh-config", - }, - { - alias: "known.example.com", - hostname: "known.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "staging", - hostname: "staging.example.com", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }).pipe( - Effect.provide( - DesktopSshEnvironment.layer().pipe( - Layer.provideMerge( - Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { - request: () => Effect.die("unexpected password prompt request"), - resolve: () => Effect.die("unexpected password prompt resolution"), - }), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - ), - ), - Effect.scoped, - ), - ); -}); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 08dd48b718f9..61b104b4ac62 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -87,7 +87,7 @@ function unexpectedPasswordPromptError(error: never): never { throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`); } -export function toSshPasswordPromptError( +function toSshPasswordPromptError( cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, ): SshPasswordPromptError { let message: string; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index dc46278a1150..b86cc275df23 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -1,6 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -144,59 +143,6 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); - it.effect("times out pending renderer prompts with a typed error", () => { - const testWindow = makeTestWindow(); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const fiber = yield* prompts - .request({ - destination: "devbox", - username: null, - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(1_000)); - const error = yield* Fiber.join(fiber).pipe(Effect.flip); - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); - assert.equal(error.destination, "devbox"); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); - - it.effect("cleans up a prompt that fails during renderer delivery", () => { - const cause = new Error("renderer unavailable"); - const testWindow = makeTestWindow({ sendError: cause }); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const error = yield* prompts - .request({ - destination: "devbox", - username: "julius", - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); - assert.equal(error.operation, "send-prompt-request"); - assert.equal(error.destination, "devbox"); - const requestId = error.requestId; - if (requestId === null) { - assert.fail("renderer delivery failures must retain their request id"); - } - assert.equal(testWindow.closedListenerCount(), 0); - - const resolveError = yield* prompts - .resolve({ requestId, password: "secret" }) - .pipe(Effect.flip); - assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); - it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), @@ -225,25 +171,4 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(testWindow.closedListenerCount(), 0); }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); - - it.effect("classifies a failed initial window availability check", () => { - const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") }); - - return Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - const error = yield* prompts - .request({ - destination: "devbox", - username: "julius", - prompt: "Enter the SSH password.", - attempt: 1, - }) - .pipe(Effect.flip); - - assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError); - assert.equal(error.operation, "check-window-before-request"); - assert.equal(error.requestId, null); - assert.deepEqual(testWindow.sentMessages, []); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); - }); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 5e59973901c1..f3f0163401ab 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -1,18 +1,13 @@ -import { DesktopHostTelemetryMessage } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -96,396 +91,6 @@ describe("DesktopTelemetryPublisher", () => { assert.isDefined(closeFiber.pollUnsafe()); }), ); - - it.effect("publishes Electron metrics and event-driven power state over NDJSON", () => - Effect.gen(function* () { - const onBattery = yield* Ref.make(false); - const systemIdleState = yield* Ref.make("active"); - let beforeSystemIdleState: Effect.Effect = Effect.void; - let metricsReadCount = 0; - const recordedMetrics: ReadonlyArray[] = []; - const simpleListeners = new Map void>(); - let thermalListener: ((state: ElectronPowerMonitor.ElectronThermalState) => void) | null = - null; - let speedLimitListener: ((limit: number) => void) | null = null; - const metrics = [ - { - pid: 4_242, - type: "Browser", - creationTime: 1_000.75, - name: "electron", - cpu: { - percentCPUUsage: 12.5, - cumulativeCPUUsage: 3.25, - idleWakeupsPerSecond: 7, - }, - memory: { - workingSetSize: 2_048, - peakWorkingSetSize: 4_096, - }, - } as Electron.ProcessMetric, - ]; - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Ref.get(onBattery), - getSystemIdleTime: Effect.succeed(5), - getSystemIdleState: () => - beforeSystemIdleState.pipe(Effect.andThen(Ref.get(systemIdleState))), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: (eventName, listener) => - Effect.sync(() => { - simpleListeners.set(eventName, listener); - }), - onThermalStateChange: (listener) => - Effect.sync(() => { - thermalListener = listener; - }), - onSpeedLimitChange: (listener) => - Effect.sync(() => { - speedLimitListener = listener; - }), - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerElectronApp(metrics, () => { - metricsReadCount += 1; - }), - layerPower, - Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { - register: () => Effect.void, - shutdown: Effect.void, - recordMetrics: (sample) => - Effect.sync(() => { - recordedMetrics.push(sample); - }), - }), - ), - ), - ); - - yield* Effect.gen(function* () { - const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; - const encoded = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); - const decoder = new TextDecoder(); - const decodeMessage = Schema.decodeUnknownEffect( - Schema.fromJsonString(DesktopHostTelemetryMessage), - ); - const messages = yield* Effect.forEach(encoded, (bytes) => - decodeMessage(decoder.decode(bytes).trim()), - ); - - const hello = messages[0]; - if (hello?.type !== "desktopTelemetryHello") { - return assert.fail("Expected the first telemetry message to be the hello."); - } - assert.equal(hello.electronPid, process.pid); - const initialSnapshot = messages[1]; - if (initialSnapshot?.type !== "desktopTelemetry") { - return assert.fail("Expected the second telemetry message to be a snapshot."); - } - assert.deepEqual(initialSnapshot.electronProcesses, []); - assert.equal(initialSnapshot.electronPid, process.pid); - assert.equal(metricsReadCount, 1); - assert.deepEqual(recordedMetrics, [metrics]); - - const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - const demandedSnapshot = Option.getOrThrow(yield* Fiber.join(nextSnapshotFiber)); - assert.equal(demandedSnapshot.electronProcesses[0]?.pid, 4_242); - assert.equal(demandedSnapshot.electronProcesses[0]?.creationTimeMs, 1_001); - assert.equal(demandedSnapshot.electronProcesses[0]?.cpuPercent, 12.5); - assert.equal(demandedSnapshot.electronProcesses[0]?.workingSetBytes, 2_048 * 1_024); - assert.equal(metricsReadCount, 2); - assert.deepEqual(recordedMetrics, [metrics, metrics]); - yield* publisher.handleControlForSource("secondary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - yield* publisher.handleControlForSource("old-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* publisher.handleControlForSource("secondary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - yield* Effect.all( - [ - publisher.removeControlSource("old-backend"), - publisher.handleControlForSource("replacement-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }), - ], - { concurrency: "unbounded", discard: true }, - ); - - const batterySnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - simpleListeners.get("on-battery")?.(); - const batterySnapshot = Option.getOrThrow(yield* Fiber.join(batterySnapshotFiber)); - assert.equal(batterySnapshot.power.onBattery, "true"); - yield* Ref.set(onBattery, true); - - const metricsAfterBatteryEvent = metricsReadCount; - yield* TestClock.adjust(Duration.millis(4_999)); - assert.equal(metricsReadCount, metricsAfterBatteryEvent); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(metricsReadCount, metricsAfterBatteryEvent + 1); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "true"); - - yield* Ref.set(onBattery, false); - const metricsBeforePolledAc = metricsReadCount; - yield* TestClock.adjust(Duration.seconds(5)); - assert.equal(metricsReadCount, metricsBeforePolledAc + 1); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.onBattery, "false"); - yield* TestClock.adjust(Duration.seconds(1)); - assert.equal(metricsReadCount, metricsBeforePolledAc + 2); - - const suspendedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - simpleListeners.get("suspend")?.(); - const suspendedSnapshot = Option.getOrThrow(yield* Fiber.join(suspendedSnapshotFiber)); - assert.isTrue(suspendedSnapshot.power.suspended); - - const constrainedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - thermalListener?.("serious"); - const constrainedSnapshot = Option.getOrThrow(yield* Fiber.join(constrainedSnapshotFiber)); - assert.equal(constrainedSnapshot.power.thermalState, "serious"); - assert.isTrue(constrainedSnapshot.power.suspended); - - const metricsAfterThermalEvent = metricsReadCount; - const recoveredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* TestClock.adjust(Duration.millis(14_999)); - assert.equal(metricsReadCount, metricsAfterThermalEvent); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(metricsReadCount, metricsAfterThermalEvent + 1); - const recoveredSnapshot = Option.getOrThrow(yield* Fiber.join(recoveredSnapshotFiber)); - assert.isFalse(recoveredSnapshot.power.suspended); - - const speedLimitSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - speedLimitListener?.(65); - const speedLimitSnapshot = Option.getOrThrow(yield* Fiber.join(speedLimitSnapshotFiber)); - assert.equal(Option.getOrNull(speedLimitSnapshot.speedLimitPercent), 65); - - const encodedSpeedLimit = yield* publisher.encoded.pipe(Stream.take(2), Stream.runCollect); - const decodedSpeedLimit = yield* decodeMessage(decoder.decode(encodedSpeedLimit[1]).trim()); - if (decodedSpeedLimit.type !== "desktopTelemetry") { - return assert.fail("Expected the encoded telemetry message to be a snapshot."); - } - assert.equal(Option.getOrNull(decodedSpeedLimit.speedLimitPercent), 65); - assert.equal(decodedSpeedLimit.electronProcesses[0]?.pid, 4_242); - assert.equal(decodedSpeedLimit.electronProcesses[0]?.creationTimeMs, 1_001); - - const metricsBeforeSecondaryOnlySample = metricsReadCount; - yield* TestClock.adjust(Duration.seconds(15)); - assert.equal(metricsReadCount, metricsBeforeSecondaryOnlySample + 1); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).electronProcesses[0]?.pid, - 4_242, - ); - - const stoppedSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("replacement-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: false, - }); - const stoppedSnapshot = Option.getOrThrow(yield* Fiber.join(stoppedSnapshotFiber)); - assert.deepEqual(stoppedSnapshot.electronProcesses, []); - const metricsAfterStopping = metricsReadCount; - const configuredSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setHostPowerIntervals", - activeIntervalMs: 7_000, - idleIntervalMs: 11_000, - }); - const configuredSequence = Option.getOrThrow( - yield* Fiber.join(configuredSnapshotFiber), - ).sequence; - - yield* TestClock.adjust(Duration.millis(6_999)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - configuredSequence, - ); - assert.equal(metricsReadCount, metricsAfterStopping + 1); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - configuredSequence + 1, - ); - assert.equal(metricsReadCount, metricsAfterStopping + 2); - - yield* Ref.set(systemIdleState, "locked"); - yield* TestClock.adjust(Duration.seconds(7)); - const lockedSequence = (yield* publisher.latest).pipe(Option.getOrThrow).sequence; - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).power.locked, "true"); - yield* TestClock.adjust(Duration.millis(10_999)); - assert.equal((yield* publisher.latest).pipe(Option.getOrThrow).sequence, lockedSequence); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - lockedSequence + 1, - ); - - yield* Ref.set(systemIdleState, "active"); - yield* TestClock.adjust(Duration.seconds(11)); - const unlockedSnapshot = (yield* publisher.latest).pipe(Option.getOrThrow); - assert.equal(unlockedSnapshot.power.locked, "false"); - assert.equal(unlockedSnapshot.power.idle, "false"); - - yield* TestClock.adjust(Duration.millis(6_999)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - unlockedSnapshot.sequence, - ); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal( - (yield* publisher.latest).pipe(Option.getOrThrow).sequence, - unlockedSnapshot.sequence + 1, - ); - - const pollStarted = yield* Deferred.make(); - const releasePoll = yield* Deferred.make(); - beforeSystemIdleState = Deferred.succeed(pollStarted, undefined).pipe( - Effect.andThen(Deferred.await(releasePoll)), - ); - const concurrentEventSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe( - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("primary-backend", { - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - yield* Deferred.await(pollStarted); - simpleListeners.get("lock-screen")?.(); - thermalListener?.("critical"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Deferred.succeed(releasePoll, undefined); - - const concurrentEventSnapshot = Option.getOrThrow( - yield* Fiber.join(concurrentEventSnapshotFiber), - ); - assert.equal(concurrentEventSnapshot.power.locked, "true"); - assert.equal(concurrentEventSnapshot.power.thermalState, "critical"); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("routes requestDesktopUpdate control messages and replays update reports", () => - Effect.gen(function* () { - const layerPower = Layer.succeed( - ElectronPowerMonitor.ElectronPowerMonitor, - ElectronPowerMonitor.ElectronPowerMonitor.of({ - isOnBatteryPower: Effect.succeed(false), - getSystemIdleTime: Effect.succeed(0), - getSystemIdleState: () => Effect.succeed("active"), - getCurrentThermalState: Effect.succeed("nominal"), - onSimpleEvent: () => Effect.void, - onThermalStateChange: () => Effect.void, - onSpeedLimitChange: () => Effect.void, - }), - ); - const layer = DesktopTelemetryPublisher.layer.pipe( - Layer.provide(Layer.mergeAll(layerElectronApp([]), layerPower, layerHistory)), - ); - - yield* Effect.gen(function* () { - const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; - - const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* publisher.handleControlForSource("test", { - version: 1, - type: "requestDesktopUpdate", - requestId: "req-9", - }); - const received = yield* Fiber.join(requestFiber); - assert.equal(Option.getOrThrow(received).requestId, "req-9"); - - const report = { - version: 1, - type: "desktopUpdateStatus", - outcome: "up-to-date", - state: { - enabled: true, - status: "up-to-date", - channel: "latest", - currentVersion: "1.2.3", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - releaseNotes: [], - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - omittedReleaseCount: 0, - }, - } as const; - yield* publisher.publishUpdateReport(report); - - const decoder = new TextDecoder(); - const decodeMessage = Schema.decodeUnknownEffect( - Schema.fromJsonString(DesktopHostTelemetryMessage), - ); - const replayed = yield* publisher.encoded.pipe( - Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())), - Stream.filter((message) => message.type === "desktopUpdateStatus"), - Stream.runHead, - ); - const replayedReport = Option.getOrThrow(replayed); - if (replayedReport.type !== "desktopUpdateStatus") { - return assert.fail("Expected a desktop update status report."); - } - assert.equal(replayedReport.outcome, "up-to-date"); - assert.equal(replayedReport.state.currentVersion, "1.2.3"); - }).pipe(Effect.provide(layer)); - }), - ); }); describe("DesktopRendererHistory", () => { diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1fc0a9e39be5..49acc3f86a48 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -484,69 +484,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("fails a prepared install when its background check stays blocked", () => { - const backgroundCheckStarted = Deferred.makeUnsafe(); - const releaseBackgroundCheck = Deferred.makeUnsafe(); - let checks = 0; - const harness = makeHarness({ - checkForUpdates: Effect.suspend(() => { - checks += 1; - return checks === 2 - ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseBackgroundCheck)), - ) - : Effect.void; - }), - }); - - return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* Queue.offer(requests, request("req-blocked-background-check")); - yield* settle; - harness.emit("update-available", { version: "1.2.4" }); - yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* settle; - - const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); - yield* Deferred.await(backgroundCheckStarted); - const commit = { - version: 1, - type: "commitDesktopUpdate", - requestId: "req-blocked-background-check", - } as const; - yield* Queue.offer(commits, commit); - yield* settle; - yield* TestClock.adjust(Duration.seconds(91)); - yield* settle; - - assert.equal(harness.quitAndInstalls(), 0); - assert.deepEqual( - terminalReports(reports) - .filter((report) => report.requestId === "req-blocked-background-check") - .map((report) => report.outcome), - ["ready-to-install", "failed"], - ); - - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* Deferred.succeed(releaseBackgroundCheck, undefined); - yield* Fiber.join(backgroundCheck); - yield* settle; - assert.equal(harness.quitAndInstalls(), 0); - - yield* Queue.offer(commits, commit); - yield* settle; - assert.deepEqual( - terminalReports(reports) - .filter((report) => report.requestId === "req-blocked-background-check") - .map((report) => report.outcome), - ["ready-to-install", "failed", "failed"], - ); - }), - ); - }); - it.effect("rejects a new preparation while an install commit is active", () => { const installStarted = Deferred.makeUnsafe(); const releaseInstall = Deferred.makeUnsafe(); @@ -640,32 +577,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("keeps every queued cancellation until its request starts", () => { - const harness = makeHarness(); - - return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => - Effect.gen(function* () { - for (let index = 0; index < 40; index += 1) { - yield* Queue.offer(cancellations, { - version: 1, - type: "cancelDesktopUpdate", - requestId: `req-queued-cancel-${index}`, - }); - } - yield* settle; - for (let index = 0; index < 40; index += 1) { - yield* Queue.offer(requests, request(`req-queued-cancel-${index}`)); - } - yield* settle; - assert.equal(harness.checkCount(), 0); - - yield* Queue.offer(requests, request("req-after-queued-cancels")); - yield* settle; - assert.equal(harness.checkCount(), 1); - }), - ); - }); - it.effect("does not install after cancellation wins the commit claim", () => { const harness = makeHarness(); @@ -757,34 +668,6 @@ describe("DesktopRemoteUpdates", () => { ); }); - it.effect("expires an uncommitted preparation", () => { - const harness = makeHarness(); - - return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => - Effect.gen(function* () { - yield* Queue.offer(requests, request("req-stale")); - yield* settle; - harness.emit("update-available", { version: "1.2.4" }); - yield* settle; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* settle; - - yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6))); - yield* Queue.offer(commits, { - version: 1, - type: "commitDesktopUpdate", - requestId: "req-stale", - }); - yield* settle; - - const outcomes = terminalReports(reports) - .filter((report) => report.requestId === "req-stale") - .map((report) => report.outcome); - assert.deepEqual(outcomes, ["ready-to-install", "failed"]); - }), - ); - }); - it.effect("reports up-to-date without installing when there is no update", () => { const harness = makeHarness(); @@ -803,24 +686,4 @@ describe("DesktopRemoteUpdates", () => { }), ); }); - - it.effect("fails fast with the disabled reason when updates are off", () => { - const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); - - return runRemoteUpdatesTest(harness, ({ reports, requests }) => - Effect.gen(function* () { - yield* Queue.offer(requests, request("req-3")); - yield* settle; - - const terminals = terminalReports(reports); - assert.equal(terminals.length, 1); - assert.equal(terminals[0]?.outcome, "failed"); - assert.equal( - terminals[0]?.reason, - "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.", - ); - assert.equal(harness.quitAndInstalls(), 0); - }), - ); - }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5233f370f7e2..d2f24143fc20 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -22,72 +21,6 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { - it("preserves complete causes for update poller and event failures", () => { - const cause = Cause.combine( - Cause.fail(new Error("updater failed")), - Cause.die(new Error("updater defect")), - ); - const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ - poller: "startup", - cause, - }); - const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ - event: "download-progress", - cause, - }); - const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ - operation: "download", - cause, - }); - const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ - action: "install", - cause, - }); - - assert.strictEqual(pollerError.cause, cause); - assert.equal(pollerError.poller, "startup"); - assert.equal(pollerError.message, "Desktop update startup poller failed."); - assert.strictEqual(eventError.cause, cause); - assert.equal(eventError.event, "download-progress"); - assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); - assert.strictEqual(reportedError.cause, cause); - assert.equal(reportedError.operation, "download"); - assert.equal(reportedError.message, "Desktop updater download operation reported an error."); - assert.strictEqual(unexpectedActionError.cause, cause); - assert.equal(unexpectedActionError.action, "install"); - assert.equal( - unexpectedActionError.message, - "Desktop update install action failed unexpectedly.", - ); - }); - - it.effect("configures the updater and runs startup checks on the test clock", () => { - const harness = makeHarness(); - - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.getState; - assert.equal(state.enabled, true); - assert.equal(state.status, "idle"); - assert.deepEqual(harness.feedUrls(), [ - { provider: "generic", url: "http://localhost:4141" }, - ]); - assert.equal(harness.listenerCount(), 6); - assert.equal(harness.checkCount(), 0); - - yield* TestClock.adjust(Duration.millis(15_000)); - assert.equal(harness.checkCount(), 1); - }), - ); - - assert.equal(harness.listenerCount(), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { const linuxState = (packageType: string | undefined) => @@ -130,105 +63,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(harness.layer)); }); - it.effect("subscribe delivers the latest state plus subsequent changes", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const { latest, changes } = yield* updates.subscribe; - assert.equal(latest.status, "idle"); - - const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild); - yield* flushCallbacks; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const observed = yield* Fiber.join(nextState); - assert.equal(Option.getOrThrow(observed).status, "available"); - assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("updates and broadcasts state from updater events", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.equal(state.availableVersion, "1.2.4"); - assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - yield* updates.setChannel("nightly"); - assert.equal(harness.fullChangelog(), true); - - harness.emit("update-available", { - version: "1.2.4-nightly.20260709.766", - releaseNotes: [ - { - version: "1.2.4-nightly.20260709.766", - note: `

What's Changed

Full Changelog

`, - }, - { - version: "1.2.4-nightly.20260709.765", - note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", - }, - { version: "1.2.4-nightly.20260709.764", note: "- Change 764" }, - { version: "1.2.4-nightly.20260709.763", note: "- Change 763" }, - { version: "1.2.4-nightly.20260709.762", note: "- Change 762" }, - { version: "1.2.4-nightly.20260709.761", note: "- Change 761" }, - { version: "1.2.4-nightly.20260709.760", note: "- Change 760" }, - ], - }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.deepEqual(state.releaseNotes, [ - { - version: "1.2.4-nightly.20260709.766", - items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], - totalItems: 1, - }, - { - version: "1.2.4-nightly.20260709.765", - items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], - totalItems: 1, - }, - { version: "1.2.4-nightly.20260709.764", items: ["Change 764"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.763", items: ["Change 763"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.762", items: ["Change 762"], totalItems: 1 }, - { version: "1.2.4-nightly.20260709.761", items: ["Change 761"], totalItems: 1 }, - ]); - assert.equal(state.omittedReleaseCount, 1); - assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); - assert.equal(harness.sentStates.at(-1)?.omittedReleaseCount, 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("checks for newer releases after an update has been downloaded", () => { const harness = makeHarness(); @@ -273,116 +107,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("reuses an update an earlier launch downloaded once its check finishes", () => { - const releaseCheck = Deferred.makeUnsafe(); - const harness = makeHarness({ - cachedDownloadSha512: "sha-1.2.4", - // electron-updater reports update-available while the check is running. - checkForUpdates: Effect.sync(() => - harness.emit("update-available", { - version: "1.2.4", - files: [{ url: "T3-Code-1.2.4.zip", sha512: "sha-1.2.4" }], - }), - ).pipe(Effect.andThen(Deferred.await(releaseCheck))), - downloadUpdate: Effect.sync(() => harness.emit("update-downloaded", { version: "1.2.4" })), - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const check = yield* Effect.forkChild(updates.check("startup")); - yield* flushCallbacks; - assert.equal(harness.downloadCount(), 0); - assert.equal((yield* updates.getState).status, "available"); - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(check); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 1); - const state = yield* updates.getState; - assert.equal(state.status, "downloaded"); - assert.equal(state.downloadedVersion, "1.2.4"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect.each([ - { name: "a newer release", cachedDownloadSha512: "older-release" }, - { name: "an interrupted download", cachedFileExists: false }, - { name: "unfinished metadata", cachedDownloadInfo: "{" }, - ])("keeps $name click-to-download", (cache) => { - const harness = makeHarness({ cachedDownloadSha512: "release-sha", ...cache }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-available", { - version: "1.2.5", - files: [{ url: "T3-Code-1.2.5.zip", sha512: "release-sha" }], - }); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 0); - assert.equal((yield* updates.getState).status, "available"); - assert.isTrue((yield* updates.download).accepted); - assert.equal(harness.downloadCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect.each(["quitting", "newer release", "no longer available"] as const)( - "abandons pending cache recovery after %s", - (change) => { - const releaseCheck = Deferred.makeUnsafe(); - const harness = makeHarness({ - cachedDownloadSha512: "cached-release", - checkForUpdates: Effect.sync(() => - harness.emit("update-available", { - version: "1.2.4", - files: [{ url: "T3-Code-1.2.4.zip", sha512: "cached-release" }], - }), - ).pipe(Effect.andThen(Deferred.await(releaseCheck))), - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - const check = yield* Effect.forkChild(updates.check("startup")); - yield* flushCallbacks; - assert.equal((yield* updates.getState).status, "available"); - assert.equal(harness.downloadCount(), 0); - - if (change === "quitting") { - const desktop = yield* DesktopState.DesktopState; - yield* Ref.set(desktop.quitting, true); - } else if (change === "newer release") { - harness.emit("update-available", { - version: "1.2.5", - files: [{ url: "T3-Code-1.2.5.zip", sha512: "new-release" }], - }); - } else { - harness.emit("update-not-available"); - } - yield* flushCallbacks; - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(check); - yield* flushCallbacks; - - assert.equal(harness.downloadCount(), 0); - assert.equal( - (yield* updates.getState).status, - change === "no longer available" ? "up-to-date" : "available", - ); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }, - ); - it.effect("preserves a queued installer when the feed has no update", () => { const harness = makeHarness(); @@ -416,39 +140,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("preserves a queued installer when the feed offers another channel", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { - version: "1.2.4", - releaseNotes: "## What's changed\n- fix: queued update", - }); - yield* flushCallbacks; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - yield* updates.check("poll"); - harness.emit("update-available", { version: "1.2.5-nightly.20260710.1" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "downloaded"); - assert.equal(state.availableVersion, "1.2.4"); - assert.equal(state.downloadedVersion, "1.2.4"); - assert.deepEqual(state.releaseNotes, [ - { version: "1.2.4", items: ["fix: queued update"], totalItems: 1 }, - ]); - assert.equal(state.omittedReleaseCount, 0); - assert.equal(state.downloadPercent, 100); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect( "rejects install while a refresh check is in progress and releases the reservation", () => @@ -518,28 +209,6 @@ describe("DesktopUpdates", () => { }), ); - it.effect("keeps raw updater event failures out of update state", () => { - const harness = makeHarness(); - const cause = new Error( - "request failed for https://user:secret@example.com/update?token=secret", - ); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("error", cause); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "error"); - assert.equal(state.message, "Desktop updater background operation reported an error."); - assert.notInclude(state.message ?? "", "secret"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("preserves a queued installer after a background updater error", () => { const harness = makeHarness(); @@ -617,49 +286,6 @@ describe("DesktopUpdates", () => { ); }); - it.effect("logs the redacted cause of an updater error event", () => { - const harness = makeHarness(); - const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), { - code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", - }); - const loggedAnnotations: Array> = []; - const logger = Logger.make(({ fiber }) => { - const annotations = fiber.getRef(References.CurrentLogAnnotations); - if (annotations.errorTag === "DesktopUpdaterReportedError") { - loggedAnnotations.push(annotations); - } - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("error", cause); - yield* flushCallbacks; - - const loggedAnnotation = loggedAnnotations.at(-1); - assert.isDefined(loggedAnnotation); - assert.equal(loggedAnnotation.operation, "background"); - assert.equal( - loggedAnnotation.cause, - "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found", - ); - assert.notInclude(JSON.stringify(loggedAnnotations), "secret"); - const state = yield* updates.getState; - assert.equal(state.message, "Desktop updater background operation reported an error."); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - TestClock.layer(), - harness.layer, - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - it.effect("recovers download state after an unexpected setup failure", () => { let disableDifferentialCalls = 0; const harness = makeHarness({ @@ -831,6 +457,7 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + // regression: #19291 an install that did apply was still flagged as not applied it.effect("does not flag an install that applied", () => { const harness = makeHarness({ installRecord: JSON.stringify({ fromVersion: "1.2.2", targetVersion: "1.2.3" }), @@ -1031,29 +658,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("recovers when quitAndInstall reports failure through an updater event", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const desktopState = yield* DesktopState.DesktopState; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - yield* updates.install; - assert.deepEqual(harness.installSteps, ["quitAndInstall"]); - harness.emit("error", new Error("native installer refused")); - yield* flushCallbacks; - - assert.isFalse(yield* Ref.get(desktopState.quitting)); - assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); - assert.equal((yield* updates.getState).errorContext, "install"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - it.effect("rejects a prepared install when the downloaded version changed", () => { const harness = makeHarness(); @@ -1109,40 +713,6 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); - it.effect("fails channel changes with a typed error while a check is in progress", () => - Effect.gen(function* () { - const checkStarted = yield* Deferred.make(); - const releaseCheck = yield* Deferred.make(); - const harness = makeHarness({ - checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCheck)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); - yield* Deferred.await(checkStarted); - - const exit = yield* Effect.exit(updates.setChannel("nightly")); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); - assert.equal(error.action, "check"); - assert.equal(error.requestedChannel, "nightly"); - } - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(checkFiber); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - it.effect("rejects checks while an update channel change is being persisted", () => Effect.gen(function* () { const channelChangeStarted = yield* Deferred.make(); @@ -1174,52 +744,9 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); - - it.effect("preserves settings failure context when an update channel cannot be persisted", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/settings.json", - cause: diskFailure, - }); - const harness = makeHarness({ setUpdateChannelError: settingsFailure }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const error = yield* updates.setChannel("nightly").pipe(Effect.flip); - - assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, settingsFailure); - assert.strictEqual(error.cause.cause, diskFailure); - assert.equal(error.message, "Failed to persist the nightly desktop update channel."); - assert.notInclude(error.message, diskFailure.message); - - const checkResult = yield* updates.check("manual"); - assert.isTrue(checkResult.checked); - assert.equal(harness.checkCount(), 1); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); }); describe("describeUpdateErrorCause", () => { - it("preserves network and nested failure reasons", () => { - const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), { - code: "ENOTFOUND", - }); - const cause = new Error("fetch failed", { cause: networkError }); - - assert.equal( - DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)), - "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com", - ); - assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]"); - }); - it("removes URLs, credentials, response bodies, and headers from summaries", () => { const messages = [ "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403", diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 4c41fbbe77d0..4fefdaa54582 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -68,17 +68,8 @@ const UpdateInfo = Schema.Struct({ // fail the decode and block the update state transition. The shape is // validated defensively in normalizeDesktopUpdateReleaseNotes. releaseNotes: Schema.optional(Schema.Unknown), - files: Schema.optional(Schema.Unknown), }); -const ReleaseFile = Schema.Struct({ url: Schema.String, sha512: Schema.String }); -type ReleaseFile = typeof ReleaseFile.Type; -const ReleaseFiles = Schema.Array(ReleaseFile); - -const CachedDownloadInfo = Schema.fromJsonString( - Schema.Struct({ fileName: Schema.String, sha512: Schema.String }), -); - const DownloadProgressInfo = Schema.Struct({ percent: Schema.Number, }); @@ -92,8 +83,6 @@ const encodeInstallRecord = Schema.encodeEffect(DesktopUpdateInstallRecord); const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig); const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo); const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo); -const decodeReleaseFiles = Schema.decodeUnknownOption(ReleaseFiles); -const decodeCachedDownloadInfo = Schema.decodeUnknownEffect(CachedDownloadInfo); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); @@ -346,32 +335,6 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -// Match electron-updater 6.8.9's MacUpdater.filterFilesForArch and Provider.findFile -// for T3's package feeds. Matching any artifact can trigger a different download. -function selectUpdateFile( - files: ReadonlyArray, - platform: NodeJS.Platform, - isDebPackage: boolean, - runtimeInfo: DesktopRuntimeInfo, -): ReleaseFile | undefined { - let candidates = files; - if (platform === "darwin") { - const isArm64File = (file: ReleaseFile) => file.url.includes("arm64"); - const preferArm64 = runtimeInfo.hostArch === "arm64" && files.some(isArm64File); - candidates = files.filter((file) => isArm64File(file) === preferArm64); - } - const extension = - platform === "darwin" - ? ".zip" - : platform === "win32" - ? ".exe" - : isDebPackage - ? ".deb" - : ".appimage"; - const packageFiles = candidates.filter((file) => file.url.toLowerCase().endsWith(extension)); - return packageFiles.find((file) => file.url.includes(runtimeInfo.appArch)) ?? packageFiles[0]; -} - /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; @@ -490,15 +453,6 @@ export const make = Effect.gen(function* () { ), ); - const awaitIdleUpdateAction = Effect.scoped( - Effect.gen(function* () { - const completions = yield* PubSub.subscribe(finishedUpdateActions); - while (Option.isSome(yield* activeUpdateAction)) { - yield* PubSub.take(completions); - } - }), - ); - const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( channel: DesktopUpdateChannel, ) { @@ -798,7 +752,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(installRunningBackendsRef, runningInstances); yield* Effect.forEach( instances, - (instance) => instance.stop({ timeout: Duration.seconds(45) }), + (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); yield* electronUpdater.quitAndInstall({ @@ -896,44 +850,6 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); - const cachedDownloadSha512 = Effect.gen(function* () { - const pendingDirectory = yield* electronUpdater.pendingDownloadDirectory; - if (Option.isNone(pendingDirectory)) return Option.none(); - const info = yield* fileSystem - .readFileString(environment.path.join(pendingDirectory.value, "update-info.json")) - .pipe(Effect.flatMap(decodeCachedDownloadInfo)); - const fileExists = yield* fileSystem.exists( - environment.path.join(pendingDirectory.value, info.fileName), - ); - return fileExists ? Option.some(info.sha512) : Option.none(); - }).pipe(Effect.orElseSucceed(() => Option.none())); - - // downloadUpdate validates the cached bytes and restores install readiness. - // Only resume the selected artifact; a cache miss would start a network download. - const resumeCachedDownload = Effect.fn("desktop.updates.resumeCachedDownload")(function* ( - version: string, - files: unknown, - ) { - const releaseFiles = decodeReleaseFiles(files); - if (Option.isNone(releaseFiles)) return; - const updateFile = selectUpdateFile( - releaseFiles.value, - environment.platform, - isDebPackage, - environment.runtimeInfo, - ); - if (!updateFile) return; - const cachedSha512 = yield* cachedDownloadSha512; - if (Option.isNone(cachedSha512) || updateFile.sha512 !== cachedSha512.value) return; - - // update-available arrives while its check still holds the action reservation. - yield* awaitIdleUpdateAction; - if (yield* Ref.get(desktopState.quitting)) return; - const state = yield* Ref.get(updateStateRef); - if (state.status !== "available" || state.availableVersion !== version) return; - yield* downloadAvailableUpdate; - }); - const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( raw: unknown, ) { @@ -973,9 +889,6 @@ export const make = Effect.gen(function* () { releaseNoteGroups: releaseNotes.length, omittedReleaseCount, }); - if (state.downloadedVersion !== info.version) { - yield* resumeCachedDownload(info.version, info.files); - } }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 57d185c0975e..008a83eab03d 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -80,26 +80,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); - it("keeps per-version order and drops empty groups", () => { - const result = normalizeDesktopUpdateReleaseNotes( - [ - { version: "1.2.3", note: "- Newer release" }, - { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, - { version: "1.2.1", note: "- Older release" }, - ], - "1.2.3", - "latest", - ); - - expect(result).toEqual({ - releaseNotes: [ - { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, - { version: "1.2.1", items: ["Older release"], totalItems: 1 }, - ], - omittedReleaseCount: 0, - }); - }); - it("drops releases from other trains before grouping on nightly", () => { // electron-updater's full changelog is "every version above the running // one", and preview sorts above nightly, so the preview cuts come first. @@ -123,6 +103,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(result.omittedReleaseCount).toBe(0); }); + // regression: #11753 prerelease entries leaked into a channel's update changelog it("keeps only stable releases on the latest channel", () => { const result = normalizeDesktopUpdateReleaseNotes( [ @@ -161,18 +142,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { expect(result.omittedReleaseCount).toBe(1); }); - it("decodes valid HTML entities", () => { - const result = normalizeDesktopUpdateReleaseNotes( - "- Fix & polish 😀", - "1.0.0", - "latest", - ); - expect(result).toEqual({ - releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], - omittedReleaseCount: 0, - }); - }); - it("ignores malformed and empty entries instead of throwing", () => { const result = normalizeDesktopUpdateReleaseNotes( [ @@ -193,13 +162,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); - it("returns an empty result for an invalid payload", () => { - expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0", "latest")).toEqual({ - releaseNotes: [], - omittedReleaseCount: 0, - }); - }); - it("does not throw on out-of-range numeric entities and keeps the literal", () => { const result = normalizeDesktopUpdateReleaseNotes( "- Broken entity �", diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts index d7afeafab879..5247931221f0 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.test.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -2,10 +2,8 @@ import { assert, describe, it } from "@effect/vitest"; import type { DesktopUpdateState } from "@t3tools/contracts"; import { - MAX_REMOTE_UPDATE_CHECKS, MAX_REMOTE_UPDATE_DOWNLOADS, nextRemoteDesktopUpdateStep, - normalizeRemoteUpdateReason, type RemoteDesktopUpdateAttempts, } from "./remoteUpdateFlow.ts"; @@ -117,23 +115,6 @@ describe("nextRemoteDesktopUpdateStep", () => { }); }); - it("rides along while a check or download is already in flight", () => { - assert.deepEqual( - nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null), - { - action: "wait", - }, - ); - assert.deepEqual( - nextRemoteDesktopUpdateStep( - makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }), - NO_ATTEMPTS, - null, - ), - { action: "wait" }, - ); - }); - it("downloads an available update until the attempt cap, then fails", () => { const available = makeState({ status: "available", availableVersion: "1.2.4" }); assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), { @@ -200,29 +181,4 @@ describe("nextRemoteDesktopUpdateStep", () => { reason: "The desktop app update failed.", }); }); - - it("checks from idle until the attempt cap, then fails", () => { - assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), { - action: "check", - }); - assert.deepEqual( - nextRemoteDesktopUpdateStep( - makeState(), - { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 }, - null, - ), - { - action: "done", - outcome: "failed", - reason: "The desktop app did not report an update result.", - }, - ); - }); - - it("drops blank reasons so the wire report still encodes", () => { - assert.equal(normalizeRemoteUpdateReason(undefined), undefined); - assert.equal(normalizeRemoteUpdateReason(""), undefined); - assert.equal(normalizeRemoteUpdateReason(" "), undefined); - assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable"); - }); }); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index 91fb45fee3a4..f7c65b35820c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -22,7 +22,7 @@ export interface RemoteDesktopUpdateAttempts { readonly downloads: number; } -export const MAX_REMOTE_UPDATE_CHECKS = 2; +const MAX_REMOTE_UPDATE_CHECKS = 2; export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { diff --git a/apps/desktop/src/updates/updateChannels.test.ts b/apps/desktop/src/updates/updateChannels.test.ts deleted file mode 100644 index acf4155fab8e..000000000000 --- a/apps/desktop/src/updates/updateChannels.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; - -describe("updateChannels", () => { - it("keeps preview builds branded as nightly but on the latest update channel", () => { - expect(isNightlyDesktopVersion("0.0.41-preview.20260911.7")).toBe(true); - expect(resolveDefaultDesktopUpdateChannel("0.0.41-preview.20260911.7")).toBe("latest"); - expect(resolveDefaultDesktopUpdateChannel("0.0.41-nightly.20260911.7")).toBe("nightly"); - }); - - it("only matches the first prerelease identifier", () => { - expect(isNightlyDesktopVersion("1.2.3-foo-preview.20260911.1")).toBe(false); - expect(isNightlyDesktopVersion("1.2.3")).toBe(false); - }); -}); diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index de57e0a8e2c7..41af75f61e96 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -6,12 +6,9 @@ import { reduceDesktopUpdateStateOnCheckStart, reduceDesktopUpdateStateOnDownloadComplete, reduceDesktopUpdateStateOnDownloadFailure, - reduceDesktopUpdateStateOnDownloadProgress, - reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, - resolveNotAppliedInstallVersion, } from "./updateMachine.ts"; const runtimeInfo = { @@ -21,41 +18,6 @@ const runtimeInfo = { } as const; describe("updateMachine", () => { - it("clears transient errors when a check starts", () => { - const state = reduceDesktopUpdateStateOnCheckStart( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "error", - message: "network", - errorContext: "check", - canRetry: true, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("checking"); - expect(state.message).toBeNull(); - expect(state.errorContext).toBeNull(); - expect(state.canRetry).toBe(false); - }); - - it("records a check failure without exposing an action", () => { - const state = reduceDesktopUpdateStateOnCheckFailure( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "checking", - }, - "network unavailable", - "2026-03-04T00:00:00.000Z", - ); - - expect(state.status).toBe("error"); - expect(state.errorContext).toBe("check"); - expect(state.canRetry).toBe(true); - }); - it("preserves an already-downloaded update while checking the feed", () => { const downloadedState = { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -205,67 +167,4 @@ describe("updateMachine", () => { expect(state.message).toBeNull(); expect(state.errorContext).toBeNull(); }); - - it("tracks available, download start, and progress cleanly", () => { - const releaseNotes = [ - { - version: "1.1.0", - items: ["feat: add update release notes"], - totalItems: 1, - }, - ]; - const available = reduceDesktopUpdateStateOnUpdateAvailable( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), - enabled: true, - status: "checking", - }, - "1.1.0", - "2026-03-04T00:00:00.000Z", - releaseNotes, - 2, - ); - const downloading = reduceDesktopUpdateStateOnDownloadStart(available); - const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); - - expect(available.status).toBe("available"); - expect(available.channel).toBe("latest"); - expect(available.releaseNotes).toBe(releaseNotes); - expect(available.omittedReleaseCount).toBe(2); - expect(downloading.releaseNotes).toBe(releaseNotes); - expect(downloading.status).toBe("downloading"); - expect(downloading.downloadPercent).toBe(0); - expect(progress.downloadPercent).toBe(55.5); - expect(progress.errorContext).toBeNull(); - }); - - it("clears release notes when checking again", () => { - const state = reduceDesktopUpdateStateOnCheckStart( - { - ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), - enabled: true, - status: "available", - availableVersion: "1.1.0-nightly.1", - releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"], totalItems: 1 }], - omittedReleaseCount: 2, - }, - "2026-03-04T00:00:00.000Z", - ); - - expect(state.releaseNotes).toEqual([]); - expect(state.omittedReleaseCount).toBe(0); - }); -}); - -describe("resolveNotAppliedInstallVersion", () => { - const record = { fromVersion: "1.2.3", targetVersion: "1.2.4" }; - - it("flags the target when the app came back on the version it installed from", () => { - expect(resolveNotAppliedInstallVersion(record, "1.2.3")).toBe("1.2.4"); - }); - - it("stays quiet when the install applied or the app runs another version", () => { - expect(resolveNotAppliedInstallVersion(record, "1.2.4")).toBeNull(); - expect(resolveNotAppliedInstallVersion(record, "1.2.5")).toBeNull(); - }); }); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 2c016c2c4d6c..1d8c21a42dfe 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -26,13 +26,6 @@ export interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly downloadUpdate?: Effect.Effect; - /** sha512 of a download an earlier launch left in electron-updater's cache. */ - readonly cachedDownloadSha512?: string; - readonly cachedDownloadInfo?: string; - readonly cachedFileExists?: boolean; - readonly updater?: ElectronUpdater.ElectronUpdater["Service"]; - readonly fileSystem?: FileSystem.FileSystem; - readonly runningUnderArm64Translation?: boolean; readonly quitAndInstall?: Effect.Effect; readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; @@ -44,8 +37,6 @@ export interface UpdatesHarnessOptions { readonly installRecord?: string | undefined; } -const PENDING_DOWNLOAD_DIRECTORY = "/cache/t3code-updater/pending"; - export function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let quitAndInstallCount = 0; @@ -74,7 +65,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } }; - const stubUpdater = { + const layerUpdater = Layer.succeed(ElectronUpdater.ElectronUpdater, { setFeedURL: (options) => Effect.sync(() => { feedUrls.push(options); @@ -93,11 +84,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { fullChangelog = value; }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, - pendingDownloadDirectory: Effect.succeed( - options.cachedDownloadSha512 === undefined - ? Option.none() - : Option.some(PENDING_DOWNLOAD_DIRECTORY), - ), checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -119,11 +105,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdater["Service"]; - const layerUpdater = Layer.succeed( - ElectronUpdater.ElectronUpdater, - options.updater ?? stubUpdater, - ); + } satisfies ElectronUpdater.ElectronUpdater["Service"]); const layerWindow = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), @@ -179,7 +161,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", - runningUnderArm64Translation: options.runningUnderArm64Translation ?? false, + runningUnderArm64Translation: false, }).pipe( Layer.provide( Layer.mergeAll( @@ -236,7 +218,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE); - const stubFileSystem = FileSystem.layerNoop({ + const layerFileSystem = FileSystem.layerNoop({ readFileString: (path) => { const installRecord = isInstallRecord(path) ? (installRecords.get(path) ?? previousInstallRecord) @@ -244,28 +226,15 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { if (installRecord !== undefined) return Effect.succeed(installRecord); return path === "/missing/resources/package-type" && options.packageType !== undefined ? Effect.succeed(options.packageType) - : path === `${PENDING_DOWNLOAD_DIRECTORY}/update-info.json` && - options.cachedDownloadSha512 !== undefined - ? Effect.succeed( - options.cachedDownloadInfo ?? - JSON.stringify({ - fileName: "update.zip", - sha512: options.cachedDownloadSha512, - }), - ) - : Effect.fail( - PlatformError.systemError({ - module: "FileSystem", - method: "readFileString", - _tag: "NotFound", - pathOrDescriptor: path, - }), - ); + : Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "readFileString", + _tag: "NotFound", + pathOrDescriptor: path, + }), + ); }, - exists: (path) => - Effect.succeed( - options.cachedFileExists !== false && path === `${PENDING_DOWNLOAD_DIRECTORY}/update.zip`, - ), makeDirectory: () => Effect.void, writeFileString: (path, data) => Effect.sync(() => { @@ -280,9 +249,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { }), }); - const layerFileSystem = options.fileSystem - ? Layer.succeed(FileSystem.FileSystem, options.fileSystem) - : stubFileSystem; const layer = DesktopUpdates.layer.pipe( Layer.provide(layerFileSystem), Layer.provideMerge(layerUpdater), diff --git a/apps/desktop/src/window/ContextMenuImage.test.ts b/apps/desktop/src/window/ContextMenuImage.test.ts index c5e3194f5bb8..dbc7a47ee910 100644 --- a/apps/desktop/src/window/ContextMenuImage.test.ts +++ b/apps/desktop/src/window/ContextMenuImage.test.ts @@ -59,22 +59,6 @@ beforeEach(() => { ); }); -it("preserves the clipboard when the context-menu frame is missing", async () => { - await copyContextMenuImage( - contents as unknown as Electron.WebContents, - { ...params, frame: null } as unknown as Electron.ContextMenuParams, - ); - expect(contents.copyImageAt).not.toHaveBeenCalled(); - expect(native.fetch).not.toHaveBeenCalled(); - expect(native.write).not.toHaveBeenCalled(); -}); - -it("keeps native image copying for the main frame", async () => { - await copyImage({ frame: mainFrame }); - expect(contents.copyImageAt).toHaveBeenCalledWith(12, 34); - expect(native.fetch).not.toHaveBeenCalled(); -}); - it.each(["https://example.com/image.png", `data:image/png;base64,${sourcePng.toString("base64")}`])( "copies an iframe image from %s", async (srcURL) => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index c0eb963d56f3..03030482918f 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -328,37 +328,6 @@ describe("DesktopApplicationMenu", () => { }), ); - it.effect("zooms from the numeric keypad without listing the commands twice", () => - Effect.gen(function* () { - for (const [accelerator, expected] of [ - ["CmdOrCtrl+numadd", "zoom-in"], - ["CmdOrCtrl+numsub", "zoom-out"], - ["CmdOrCtrl+num0", "zoom-reset"], - ] as const) { - const selectedAction = yield* Deferred.make(); - const applicationMenuTemplate = - yield* Deferred.make(); - - yield* configureMenu(selectedAction, applicationMenuTemplate); - - const template = yield* Deferred.await(applicationMenuTemplate); - const viewMenu = template.find((item) => item.label === "View"); - if (!viewMenu || !Array.isArray(viewMenu.submenu)) { - throw new Error("Expected View menu submenu to be an array."); - } - const item = viewMenu.submenu.find((entry) => entry.accelerator === accelerator); - assert.isDefined(item); - assert.isFalse(item.visible); - if (typeof item.click !== "function") { - throw new Error(`Expected ${accelerator} menu item to have a click handler.`); - } - - item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); - assert.equal(yield* Deferred.await(selectedAction), expected); - } - }), - ); - it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8daf9017d242..40ab8c80a93e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -8,14 +8,11 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; -import * as NodeEvents from "node:events"; import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ @@ -27,8 +24,6 @@ vi.mock("electron", async (importOriginal) => ({ setUserAgent: vi.fn(), })), }, - BrowserWindow: { getAllWindows: () => [], getFocusedWindow: () => null }, - app: { setBadgeCount: vi.fn() }, screen: { getAllDisplays: vi.fn(() => [ { @@ -38,43 +33,6 @@ vi.mock("electron", async (importOriginal) => ({ }, })); -import * as NetService from "@t3tools/shared/Net"; -import { HttpClient } from "effect/http"; -import * as BrowserImport from "../preview/BrowserImport/BrowserImport.ts"; -import * as DesktopBackendConfiguration from "../backend/DesktopBackendConfiguration.ts"; -import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; -import * as DesktopCliCommand from "../app/DesktopCliCommand.ts"; -import * as DesktopConnectionCatalogStore from "../app/DesktopConnectionCatalogStore.ts"; -import * as DesktopWebLinks from "../app/DesktopWebLinks.ts"; -import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "../ssh/DesktopSshPasswordPrompts.ts"; -import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; -import * as MacPermissions from "../permissions/MacPermissions.ts"; - -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Stream from "effect/Stream"; -import * as DesktopApp from "../app/DesktopApp.ts"; -import * as DesktopAppActivation from "../app/DesktopAppActivation.ts"; -import * as DesktopAppIdentity from "../app/DesktopAppIdentity.ts"; -import * as DesktopClerk from "../app/DesktopClerk.ts"; -import * as DesktopLegacyLocalStorage from "../app/DesktopLegacyLocalStorage.ts"; -import * as DesktopLifecycle from "../app/DesktopLifecycle.ts"; -import * as DesktopLinuxUrlHandler from "../app/DesktopLinuxUrlHandler.ts"; -import * as DesktopPreReadyPlatform from "../app/DesktopPreReadyPlatform.ts"; -import * as DesktopShutdown from "../app/DesktopShutdown.ts"; -import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as ElectronDialog from "../electron/ElectronDialog.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; -import * as DesktopIpc from "../ipc/DesktopIpc.ts"; -import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts"; -import * as DesktopQuickMessage from "../quickMessage/DesktopQuickMessage.ts"; -import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; -import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; -import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; -import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; -import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; - import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -87,12 +45,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { - MENU_ACTION_CHANNEL, - SNAP_SHOT_EVENT_CHANNEL, - TRACKPAD_SCROLL_END_CHANNEL, - WINDOW_FULLSCREEN_STATE_CHANNEL, -} from "../ipc/channels.ts"; +import { SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -171,7 +124,6 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, - close: window.close, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, isDestroyed: window.isDestroyed, @@ -396,10 +348,7 @@ function layerTest(input: { ); } -const makeSplashScenario = ( - createOutcomes: readonly (Electron.BrowserWindow | null)[], - settings = DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, -) => +const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => Effect.gen(function* () { const createdWindows = yield* Ref.make([]); const createCalls = yield* Ref.make(0); @@ -465,7 +414,7 @@ const makeSplashScenario = ( } satisfies ElectronWindow.ElectronWindow["Service"]; const layer = DesktopWindow.layer.pipe( - Layer.provideMerge( + Layer.provide( Layer.mergeAll( layerDesktopAssets, Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, { @@ -474,7 +423,7 @@ const makeSplashScenario = ( shutdown: Effect.void, }), layerDesktopEnvironment, - DesktopAppSettings.layerTest(settings), + DesktopAppSettings.layerTest(), layerDesktopClientSettings, layerDesktopServerExposure, DesktopState.layer, @@ -489,17 +438,11 @@ const makeSplashScenario = ( layerElectronTheme, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ - configure: Effect.void, bridgeEnabled: false, installSessionHandlers: () => {}, attachGuest: () => () => {}, }), Layer.mock(PreviewManager.PreviewManager)({ - subscribeStateChanges: () => Effect.void, - subscribeRecordingFrames: () => Effect.void, - subscribeRecordingInputs: () => Effect.void, - subscribePointerEvents: () => Effect.void, - subscribeOpenLinks: () => Effect.void, getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), @@ -516,290 +459,6 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); describe("DesktopWindow", () => { - it.effect.each([ - { mode: "native", localEnvironmentEnabled: true, wslBackendEnabled: false, wslOnly: false }, - { mode: "WSL-only", localEnvironmentEnabled: true, wslBackendEnabled: true, wslOnly: true }, - { mode: "dual", localEnvironmentEnabled: true, wslBackendEnabled: true, wslOnly: false }, - { mode: "disabled", localEnvironmentEnabled: false, wslBackendEnabled: false, wslOnly: false }, - ])("hands startup from the splash to the main window in $mode mode", (settings) => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario( - settings.localEnvironmentEnabled ? [splash.window, main.window] : [main.window], - { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, ...settings }, - ); - const bootstrapped = yield* Deferred.make(); - const windowsAtBackendStart: number[] = []; - const primary = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Primary"), - start: Ref.get(scenario.createCalls).pipe( - Effect.tap((count) => Effect.sync(() => windowsAtBackendStart.push(count))), - Effect.asVoid, - ), - stop: () => Effect.void, - currentConfig: Effect.succeedNone, - snapshot: Effect.never, - waitForReady: () => Effect.succeed(false), - } satisfies DesktopBackendPool.DesktopBackendInstance; - const startupLayer = Layer.mergeAll( - // IPC registration captures these services but startup does not invoke their handlers. - Layer.mock(BrowserImport.BrowserImport)({}), - Layer.mock(DesktopBackendConfiguration.DesktopBackendConfiguration)({}), - Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth)({}), - Layer.mock(DesktopCliCommand.DesktopCliCommand)({}), - Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore)({}), - Layer.mock(DesktopWebLinks.DesktopWebLinks)({}), - Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment)({}), - Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts)({}), - Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment)({}), - Layer.mock(MacPermissions.MacPermissions)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - Layer.mock(NetService.NetService)({}), - Layer.mock(ElectronApp.ElectronApp)({ - whenReady: Effect.void, - setPath: () => Effect.void, - on: () => Effect.void, - }), - Layer.mock(DesktopAppIdentity.DesktopAppIdentity)({ - resolveUserDataPath: Effect.succeed("/unused"), - configure: Effect.void, - }), - Layer.mock(DesktopLifecycle.DesktopLifecycle)({ register: Effect.void }), - Layer.mock(DesktopClerk.DesktopClerk)({ configure: Effect.void }), - Layer.mock(DesktopApplicationMenu.DesktopApplicationMenu)({ configure: Effect.void }), - Layer.mock(DesktopLinuxUrlHandler.DesktopLinuxUrlHandler)({ register: Effect.void }), - Layer.mock(DesktopShellEnvironment.DesktopShellEnvironment)({ - installIntoProcess: Effect.succeed(true), - }), - Layer.succeed(DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, { - linux: null, - linuxPasswordStoreCommandLine: null, - }), - Layer.mock(ElectronSafeStorage.ElectronSafeStorage)({}), - Layer.mock(ElectronProtocol.ElectronProtocol)({ - registerDesktopProtocol: () => Effect.void, - }), - Layer.mock(ElectronDialog.ElectronDialog)({}), - Layer.mock(DesktopIpc.DesktopIpc)({ - handle: () => Effect.void, - handleSync: () => Effect.void, - }), - Layer.mock(DesktopLegacyLocalStorage.DesktopLegacyLocalStorage)({ - load: () => Effect.void, - }), - Layer.mock(DesktopSnapShot.DesktopSnapShot)({ initialize: Effect.void }), - Layer.mock(DesktopQuickMessage.DesktopQuickMessage)({ initialize: Effect.void }), - Layer.mock(DesktopAppActivation.DesktopAppActivation)({ start: Effect.void }), - Layer.mock(DesktopWslBackend.DesktopWslBackend)({ reconcile: Effect.void }), - Layer.mock(DesktopBackendPool.DesktopBackendPool)({ - primary: Effect.succeed(primary), - list: Effect.succeed([]), - }), - Layer.mock(DesktopServerExposure.DesktopServerExposure)({ - configureFromSettings: () => - Effect.succeed({ - mode: "local-only", - advertisedHost: null, - endpointUrl: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - backendConfig: Effect.succeed({ - port: 3773, - bindHost: "127.0.0.1", - httpBaseUrl: new URL("http://127.0.0.1:3773"), - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - }), - Layer.mock(DesktopUpdates.DesktopUpdates)({ - configure: Effect.void, - subscribe: Effect.never, - }), - Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher)({ - updateRequests: Stream.empty, - updateCancellations: Stream.empty, - updateCommits: Stream.empty, - }), - Layer.mock(DesktopShutdown.DesktopShutdown)({ - awaitRequest: Deferred.succeed(bootstrapped, undefined).pipe( - Effect.andThen(Effect.never), - ), - markComplete: Effect.void, - }), - Layer.succeed(HostProcess.Platform, "darwin"), - NodeServices.layer, - ); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const app = yield* DesktopApp.program.pipe(Effect.forkScoped); - yield* Effect.raceFirst(Deferred.await(bootstrapped), Fiber.join(app)); - - assert.deepEqual(windowsAtBackendStart, settings.localEnvironmentEnabled ? [1] : []); - assert.equal(yield* Ref.get(scenario.createCalls), 1); - if (settings.localEnvironmentEnabled) { - assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(yield* Ref.get(scenario.createCalls), 2); - } - assert.deepEqual(yield* Ref.get(scenario.mainWindow), Option.some(main.window)); - assert.equal(splash.close.mock.calls.length, 0); - const readyToShow = main.windowListeners.get("ready-to-show"); - assert.isDefined(readyToShow); - readyToShow(); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [main.window]); - assert.equal(splash.close.mock.calls.length, settings.localEnvironmentEnabled ? 1 : 0); - readyToShow(); - assert.equal(splash.close.mock.calls.length, settings.localEnvironmentEnabled ? 1 : 0); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(scenario.layer, startupLayer, DesktopState.layer)), - ); - }), - ); - - it.effect("shows native context menus for browser guests and sign-in popups", () => - Effect.gen(function* () { - const host = makeFakeBrowserWindow(); - const popup = makeFakeBrowserWindow(); - let focusedContents: unknown = host.window.webContents; - const makeContents = () => { - const contents = Object.assign(new NodeEvents.EventEmitter(), { - mainFrame: { - routingId: 7, - isDestroyed: vi.fn(() => false), - } as unknown as Electron.WebFrameMain, - isDestroyed: vi.fn(() => false), - focus: vi.fn(() => { - focusedContents = contents; - }), - copyImageAt: vi.fn(), - replaceMisspelling: vi.fn(), - }); - return contents; - }; - const guest = makeContents(); - const popupContents = makeContents(); - const popupWindow = { ...popup.window, webContents: popupContents }; - const menus = yield* Queue.unbounded<{ - input: ElectronMenu.ElectronMenuTemplateInput; - focusedContents: unknown; - }>(); - const copiedTexts: string[] = []; - const layer = layerTest({ - window: host.window, - createCount: yield* Ref.make(0), - mainWindow: yield* Ref.make>(Option.none()), - copiedTexts, - onPopupTemplate: (input) => - Queue.offer(menus, { input, focusedContents }).pipe(Effect.asVoid), - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const attach = host.webContentsListeners.get("did-attach-webview"); - assert.isDefined(attach); - attach({}, guest); - attach({}, guest); - guest.emit("did-create-window", popupWindow); - guest.emit("did-create-window", popupWindow); - - for (const [contents, owner] of [ - [guest, host.window], - [popupContents, popupWindow], - ] as const) { - const frame = { routingId: 7 } as Electron.WebFrameMain; - const preventDefault = vi.fn(); - const params = { - frame, - x: 12, - y: 34, - misspelledWord: "helo", - dictionarySuggestions: ["hello"], - linkURL: "", - mediaType: "none", - editFlags: { canCut: false, canCopy: true, canPaste: true, canSelectAll: true }, - }; - focusedContents = host.window.webContents; - contents.emit("context-menu", { preventDefault }, params); - const menu = yield* Queue.take(menus); - assert.strictEqual(menu.input.window, owner); - assert.strictEqual(menu.input.frame, frame); - assert.strictEqual(menu.focusedContents, contents); - assert.equal(preventDefault.mock.calls.length, 1); - assert.deepEqual( - menu.input.template.filter((item) => item.role), - [ - { role: "cut", enabled: false }, - { role: "copy", enabled: true }, - { role: "paste", enabled: true }, - { role: "selectAll", enabled: true }, - ], - ); - const correction = menu.input.template.find((item) => item.label === "hello"); - assert.isDefined(correction?.click); - correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.deepEqual(contents.replaceMisspelling.mock.calls, [["hello"]]); - - contents.emit( - "context-menu", - { preventDefault }, - { - ...params, - frame: contents.mainFrame, - misspelledWord: "", - dictionarySuggestions: [], - mediaType: "image", - linkURL: "https://example.com/image.png", - }, - ); - const imageMenu = (yield* Queue.take(menus)).input; - assert.strictEqual(imageMenu.frame, contents.mainFrame); - const copyImage = imageMenu.template.find((item) => item.label === "Copy Image"); - const copyLink = imageMenu.template.find((item) => item.label === "Copy Link"); - assert.isDefined(copyImage?.click); - assert.isDefined(copyLink?.click); - copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - copyLink.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.deepEqual(contents.copyImageAt.mock.calls, [[12, 34]]); - assert.equal(copiedTexts.at(-1), "https://example.com/image.png"); - - contents.isDestroyed.mockReturnValue(true); - correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent); - assert.equal(contents.replaceMisspelling.mock.calls.length, 1); - assert.equal(contents.copyImageAt.mock.calls.length, 1); - assert.equal(yield* Queue.size(menus), 0); - } - }).pipe(Effect.provide(layer)); - }), - ); - - it("leaves fullscreen before concealing a pending quit", () => { - const fakeWindow = makeFakeBrowserWindow(); - - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); - - fakeWindow.setOpacity.mockClear(); - fakeWindow.isFullScreen.mockReturnValue(true); - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.deepEqual(fakeWindow.setFullScreen.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.setOpacity.mock.calls, [[0]]); - - fakeWindow.setOpacity.mockClear(); - fakeWindow.isFullScreen.mockReturnValue(false); - fakeWindow.isDestroyed.mockReturnValue(true); - DesktopWindow.concealPendingQuitWindow(fakeWindow.window); - assert.equal(fakeWindow.setOpacity.mock.calls.length, 0); - }); - it("restores bounds only when the window fits within a connected display", () => { const persistedBounds = { x: 2040, y: 80, width: 1320, height: 880 }; const displays = [ @@ -817,62 +476,6 @@ describe("DesktopWindow", () => { ); }); - it("recognizes only same-origin renderer navigations", () => { - assert.isTrue( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "t3code://app/settings/connections", - }), - ); - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "https://accounts.microsoft.com/oauth", - }), - ); - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl: "not a url", - }), - ); - }); - - it.effect("does not open a development window until the backend is ready", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.activate; - assert.equal(yield* Ref.get(createCount), 0); - - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - assert.equal(yield* Ref.get(createCount), 1); - assert.equal(createdWindowOptions[0]?.width, 1100); - assert.equal(createdWindowOptions[0]?.height, 780); - assert.equal(createdWindowOptions[0]?.minWidth, 600); - assert.equal(createdWindowOptions[0]?.minHeight, 620); - assert.isUndefined(createdWindowOptions[0]?.x); - assert.isUndefined(createdWindowOptions[0]?.y); - assert.isTrue(createdWindowOptions[0]?.disableAutoHideCursor); - assert.isFalse(createdWindowOptions[0]?.webPreferences?.backgroundThrottling); - assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); - assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); - assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => @@ -949,30 +552,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("forwards native trackpad release to the renderer", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const send = vi.spyOn(fakeWindow.window.webContents, "send"); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - const onInput = fakeWindow.webContentsListeners.get("input-event"); - if (!onInput) return yield* Effect.die("input-event listener was not registered"); - onInput({}, { type: "gestureScrollUpdate" }); - assert.notInclude( - send.mock.calls.map(([channel]) => channel), - TRACKPAD_SCROLL_END_CHANNEL, - ); - onInput({}, { type: "gestureScrollEnd" }); - assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL)); - }).pipe(Effect.provide(layer)); - }), - ); - // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. @@ -1075,67 +654,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - for (const direction of ["in", "in", "out", "reset", "out"] as const) { - yield* desktopWindow.zoomMain(direction); - const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; - assert.isDefined(position); - const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); - assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); - assert.equal(position.x, 16); - } - - fakeWindow.isFullScreen.mockReturnValue(true); - fakeWindow.setWindowButtonPosition.mockClear(); - yield* desktopWindow.zoomMain("reset"); - assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0); - - fakeWindow.isFullScreen.mockReturnValue(false); - fakeWindow.windowListeners.get("leave-full-screen")?.(); - assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("uses the persisted main window bounds when opening the window", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - desktopSettings: { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 768, height: 780 }, - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - assert.equal(createdWindowOptions[0]?.width, 768); - assert.equal(createdWindowOptions[0]?.height, 780); - assert.equal(createdWindowOptions[0]?.x, 120); - assert.equal(createdWindowOptions[0]?.y, 80); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("does not open main or splash windows once quitting has started", () => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); @@ -1270,102 +788,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("restores the persisted maximized state", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - desktopSettings: { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - mainWindowBounds: { x: 120, y: 80, width: 1320, height: 880 }, - mainWindowMaximized: true, - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - assert.equal(fakeWindow.maximize.mock.calls.length, 0); - const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); - if (!readyToShow) { - return yield* Effect.die("window ready-to-show listener was not registered"); - } - readyToShow(); - assert.equal(fakeWindow.maximize.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("re-enables background throttling on first reveal", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - assert.equal(fakeWindow.setBackgroundThrottling.mock.calls.length, 0); - const readyToShow = fakeWindow.windowListeners.get("ready-to-show"); - if (!readyToShow) { - return yield* Effect.die("window ready-to-show listener was not registered"); - } - readyToShow(); - assert.deepEqual(fakeWindow.setBackgroundThrottling.mock.calls, [[true]]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("debounces move and resize bounds updates", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const move = fakeWindow.windowListeners.get("move"); - const resize = fakeWindow.windowListeners.get("resize"); - if (!move || !resize) { - return yield* Effect.die("window bounds listeners were not registered"); - } - - fakeWindow.getBounds.mockReturnValue({ x: 120, y: 80, width: 1280, height: 840 }); - move(); - yield* TestClock.adjust(250); - - fakeWindow.getBounds.mockReturnValue({ x: 160, y: 100, width: 664, height: 780 }); - resize(); - yield* TestClock.adjust(499); - assert.deepEqual(mainWindowBoundsUpdates, []); - - yield* TestClock.adjust(1); - yield* Effect.promise(() => Promise.resolve()); - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 160, y: 100, width: 664, height: 780 }]); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("persists normal bounds and state for a maximized window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1403,74 +825,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("persists normal bounds and state from the native maximize event", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const mainWindowMaximizedUpdates: boolean[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - mainWindowMaximizedUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const maximize = fakeWindow.windowListeners.get("maximize"); - if (!maximize) { - return yield* Effect.die("window maximize listener was not registered"); - } - - fakeWindow.isMaximized.mockReturnValue(true); - fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); - fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); - maximize(); - yield* TestClock.adjust(500); - yield* Effect.promise(() => Promise.resolve()); - - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); - assert.deepEqual(mainWindowMaximizedUpdates, [true]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("does not persist bounds that fail the domain schema", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 100, y: 80, width: 599, height: 620 }); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const resize = fakeWindow.windowListeners.get("resize"); - if (!resize) { - return yield* Effect.die("window resize listener was not registered"); - } - resize(); - yield* TestClock.adjust(500); - yield* Effect.promise(() => Promise.resolve()); - - assert.deepEqual(mainWindowBoundsUpdates, []); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("preserves unrestorable bounds until the user changes the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1511,42 +865,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("flushes normal bounds when fullscreen before the debounce completes", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: 0, y: 0, width: 1920, height: 1080 }); - fakeWindow.getNormalBounds.mockReturnValue({ x: 200, y: 130, width: 1400, height: 940 }); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - mainWindowBoundsUpdates, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const resize = fakeWindow.windowListeners.get("resize"); - if (!resize) { - return yield* Effect.die("window resize listener was not registered"); - } - resize(); - yield* TestClock.adjust(250); - fakeWindow.isFullScreen.mockReturnValue(true); - - yield* desktopWindow.flushMainWindowBounds; - - assert.deepEqual(mainWindowBoundsUpdates, [{ x: 200, y: 130, width: 1400, height: 940 }]); - assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("flushes normal bounds when minimized before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1583,54 +901,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("logs display lookup failures before falling back to the default size", () => - Effect.gen(function* () { - const displayLookupFailure = new Error("screen API unavailable"); - vi.mocked(Electron.screen.getAllDisplays).mockImplementationOnce(() => { - throw displayLookupFailure; - }); - const logRecords: Array<{ - readonly message: unknown; - readonly annotations: Readonly>; - }> = []; - const logger = Logger.make(({ fiber, message }) => { - logRecords.push({ - message, - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const createdWindowOptions: Electron.BrowserWindowConstructorOptions[] = []; - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - createdWindowOptions, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - }).pipe( - Effect.provide(Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false }))), - ); - - const warning = logRecords.find( - (record) => - Array.isArray(record.message) && - record.message[0] === "failed to read connected displays; using defaults", - ); - assert.isDefined(warning); - assert.strictEqual(warning.annotations.cause, displayLookupFailure); - assert.equal(createdWindowOptions[0]?.width, 1100); - assert.equal(createdWindowOptions[0]?.height, 780); - assert.isUndefined(createdWindowOptions[0]?.x); - assert.isUndefined(createdWindowOptions[0]?.y); - }), - ); - it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1688,145 +958,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("publishes native macOS fullscreen changes to the renderer", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); - const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); - if (!enterFullscreen || !leaveFullscreen) { - return yield* Effect.die("fullscreen listeners were not registered"); - } - - enterFullscreen(); - leaveFullscreen(); - assert.deepEqual(fakeWindow.send.mock.calls, [ - [WINDOW_FULLSCREEN_STATE_CHANNEL, true], - [WINDOW_FULLSCREEN_STATE_CHANNEL, false], - ]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect( - "recovers killed renderers without reloading indefinitely or reviving closed windows", - () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow }); - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.createMain; - const onGone = fakeWindow.webContentsListeners.get("render-process-gone"); - const onLoaded = fakeWindow.webContentsListeners.get("did-finish-load"); - if (!onGone || !onLoaded) - return yield* Effect.die("renderer lifecycle listeners missing"); - - onGone({}, { reason: "clean-exit", exitCode: 0 }); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 1); - - for (let attempt = 0; attempt < 4; attempt += 1) { - onGone({}, { reason: "killed", exitCode: 9 }); - yield* TestClock.adjust(500); - onLoaded(); - } - assert.equal(fakeWindow.loadURL.mock.calls.length, 4); - - yield* TestClock.adjust(60_000); - onGone({}, { reason: "killed", exitCode: 9 }); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 5); - assert.equal(yield* Ref.get(createCount), 1); - - onGone({}, { reason: "killed", exitCode: 9 }); - fakeWindow.isDestroyed.mockReturnValue(true); - yield* TestClock.adjust(500); - assert.equal(fakeWindow.loadURL.mock.calls.length, 5); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("recovers when the development renderer is temporarily unreachable", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - - const didFailLoad = fakeWindow.webContentsListeners.get("did-fail-load"); - const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); - if (!didFailLoad || !didFinishLoad) { - return yield* Effect.die("renderer load listeners were not registered"); - } - - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); - assert.equal(fakeWindow.loadURL.mock.calls.length, 1); - - yield* TestClock.adjust(100); - assert.deepEqual(fakeWindow.loadURL.mock.calls, [ - ["t3code-dev://app/"], - ["t3code-dev://app/"], - ]); - assert.equal(fakeWindow.reload.mock.calls.length, 0); - - didFailLoad({}, -9, "ERR_UNEXPECTED", "t3code-dev://app/", true); - didFinishLoad(); - yield* TestClock.adjust(250); - assert.equal(fakeWindow.loadURL.mock.calls.length, 2); - assert.equal(fakeWindow.reload.mock.calls.length, 0); - }).pipe(Effect.provide(layer)); - }), - ); - - it("retries only transient failures for the development renderer", () => { - assert.isTrue( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -102, - isMainFrame: true, - validatedUrl: "t3code-dev://app/", - }), - ); - assert.isFalse( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -3, - isMainFrame: true, - validatedUrl: "t3code-dev://app/", - }), - ); - assert.isFalse( - DesktopWindow.isRetryableDevelopmentRendererLoadFailure({ - applicationUrl: "t3code-dev://app/", - errorCode: -102, - isMainFrame: true, - validatedUrl: "https://example.com/", - }), - ); - }); - it.effect("opens safe off-origin renderer navigations in the system browser", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1895,68 +1026,6 @@ describe("DesktopWindow", () => { }), ); - it.effect( - "re-reveals the connecting splash on activate while the backend is still cold-booting", - () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - assert.equal(yield* Ref.get(scenario.createCalls), 1); - - yield* desktopWindow.activate; - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - - it.effect("does not dispatch menu actions to the splash before the backend is ready", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window, main.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - yield* desktopWindow.dispatchMenuAction("open-settings"); - - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.equal(splash.send.mock.calls.length, 0); - assert.equal(main.send.mock.calls.length, 0); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - - it.effect("dispatches menu actions after backend readiness when no main window exists", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const main = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window, null, main.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - - yield* desktopWindow.showConnectingSplash; - const readyExit = yield* Effect.exit( - desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), - ); - assert.equal(readyExit._tag, "Failure"); - - yield* desktopWindow.dispatchMenuAction("open-settings"); - - assert.equal(yield* Ref.get(scenario.createCalls), 3); - assert.deepEqual(main.send.mock.calls, [[MENU_ACTION_CHANNEL, "open-settings"]]); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - it.effect("delivers capture completion and late errors without taking focus back", () => Effect.gen(function* () { const operations: Array = []; @@ -1997,51 +1066,6 @@ describe("DesktopWindow", () => { }), ); - it.effect("delivers the renderer event even when the reveal fails", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>(Option.none()); - const layer = layerTest({ - window: fakeWindow.window, - createCount, - mainWindow, - onReveal: () => { - throw new Error("another process kept the foreground"); - }, - }); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); - yield* Effect.exit( - desktopWindow.dispatchSnapShotEvent({ type: "started", id: captureOne }), - ); - - assert.deepEqual(fakeWindow.send.mock.calls, [ - [SNAP_SHOT_EVENT_CHANNEL, { type: "started", id: captureOne }], - ]); - }).pipe(Effect.provide(layer)); - }), - ); - - it.effect("leaves a completed capture pending while only the connecting splash exists", () => - Effect.gen(function* () { - const splash = makeFakeBrowserWindow(); - const scenario = yield* makeSplashScenario([splash.window]); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.showConnectingSplash; - yield* desktopWindow.dispatchSnapShotEvent({ type: "ready", id: captureOne }); - - assert.equal(yield* Ref.get(scenario.createCalls), 1); - assert.equal(splash.send.mock.calls.length, 0); - assert.deepEqual(yield* Ref.get(scenario.revealedWindows), []); - }).pipe(Effect.provide(scenario.layer)); - }), - ); - it.effect("does not reopen a closed main window for a completed capture", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -2063,34 +1087,4 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(layer)); }), ); - - it.effect("waits for a loading renderer without foregrounding it when capture is ready", () => - Effect.gen(function* () { - const fakeWindow = makeFakeBrowserWindow(); - const createCount = yield* Ref.make(0); - const mainWindow = yield* Ref.make>( - Option.some(fakeWindow.window), - ); - const onReveal = vi.fn(); - const layer = layerTest({ window: fakeWindow.window, createCount, mainWindow, onReveal }); - vi.mocked(fakeWindow.window.webContents.isLoadingMainFrame).mockReturnValue(true); - - yield* Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchSnapShotEvent({ type: "ready", id: captureOne }); - assert.equal(fakeWindow.send.mock.calls.length, 0); - const onLoad = fakeWindow.webContentsOnce.mock.calls.find( - ([event]) => event === "did-finish-load", - )?.[1]; - assert.isDefined(onLoad); - onLoad?.(); - - assert.deepEqual(fakeWindow.send.mock.calls, [ - [SNAP_SHOT_EVENT_CHANNEL, { type: "ready", id: captureOne }], - ]); - assert.equal(onReveal.mock.calls.length, 0); - assert.equal(yield* Ref.get(createCount), 0); - }).pipe(Effect.provide(layer)); - }), - ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 8a10de409834..297c67ddace0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -176,16 +176,16 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean, text: string): string { +function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; - const html = `
${text}
`; + const html = `
Connecting to WSL…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -export function isSameOriginRendererNavigation(input: { +function isSameOriginRendererNavigation(input: { readonly applicationUrl: string; readonly navigationUrl: string; }): boolean { @@ -196,7 +196,7 @@ export function isSameOriginRendererNavigation(input: { } } -export function isRetryableDevelopmentRendererLoadFailure(input: { +function isRetryableDevelopmentRendererLoadFailure(input: { readonly applicationUrl: string; readonly errorCode: number; readonly isMainFrame: boolean; @@ -212,7 +212,7 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { ); } -export function concealPendingQuitWindow( +function concealPendingQuitWindow( window: Pick< Electron.BrowserWindow, "isDestroyed" | "isFullScreen" | "setFullScreen" | "setOpacity" @@ -315,7 +315,11 @@ export const make = Effect.gen(function* () { } }); - // Window lookups fall back to the first window, which may be the splash. + // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", + // which during WSL-only boot is the connecting splash. The splash is never + // registered via setMain, so it must be treated as "no real main window" -- + // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never + // open (or retry) the real main. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -379,8 +383,8 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, - minWidth: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.width, - minHeight: DesktopAppSettings.MIN_MAIN_WINDOW_SIZE.height, + minWidth: 840, + minHeight: 620, show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), @@ -899,11 +903,6 @@ export const make = Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; if (yield* Ref.get(desktopState.quitting)) return; - const settings = yield* desktopSettings.get; - const text = - settings.wslOnly === true && settings.wslBackendEnabled === true - ? "Connecting to WSL…" - : "Starting T3 Code…"; const splash = yield* electronWindow.create({ width: 360, height: 220, @@ -937,7 +936,7 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors, text)); + void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) => diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 591fad185a5d..bebcd66e61c6 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -4,12 +4,10 @@ import { makeQuitShortcutHandler, QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, - QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; import type { QuitHoldKeyInput } from "./QuitHold.ts"; import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; -const HOLD_DOWN = { state: "down", mode: "hold" } as const; const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; const UP = { state: "up" } as const; @@ -68,165 +66,6 @@ describe("makeQuitShortcutHandler", () => { vi.useRealTimers(); }); - it("shows the hint on a tap without quitting, even when the release is never seen", async () => { - // macOS suppresses the letter's keyUp while Cmd is held, so a tap may - // produce no keyUp at all. Quit must still not fire. - const harness = makeHarness(); - await harness.send(makeInput({})); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN]); - - vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("conceals a completed hold, then quits after release", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("keeps a concealed hold committed when another key is pressed", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - - await harness.send(makeInput({ key: "Shift", shift: true })); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("keeps a concealed hold committed through a fresh Cmd+Q press", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - - await harness.send(makeInput({})); - expect(harness.concealWindow).toHaveBeenCalledTimes(1); - expect(harness.quit).not.toHaveBeenCalled(); - - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("quits when a completed hold goes quiet without release events", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); - - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("waits for slow repeats to stop before quitting", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - - vi.advanceTimersByTime(300); - await harness.send(makeInput({ isAutoRepeat: true })); - vi.advanceTimersByTime(900); - await harness.send(makeInput({ isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(300); - await harness.send(makeInput({ isAutoRepeat: true })); - vi.advanceTimersByTime(1_799); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("uses the initial repeat delay when the first repeat completes the hold", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - - vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + 100); - await harness.send(makeInput({ isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1_999); - expect(harness.quit).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("waits for Q release when Cmd is released first", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - harness.preventDefault.mockClear(); - await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false }); - expect(harness.preventDefault).toHaveBeenCalled(); - expect(harness.quit).not.toHaveBeenCalled(); - await harness.send(makeInput({ type: "keyUp", meta: false })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); - - it("commits a concealed hold when the last Q repeat is never released", async () => { - // macOS can drop the final Q keyUp. The quit must land on its own once - // repeats stop, rather than sitting armed until an unrelated key arrives. - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - await harness.send(makeInput({ meta: false, isAutoRepeat: true })); - - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); - expect(harness.quit).toHaveBeenCalledTimes(1); - - harness.quit.mockClear(); - await harness.send(makeInput({ key: "Meta" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 4); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("does not quit when the hold stops before the duration", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(500); - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("cancels the hold when the modifier is released first", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); - expect(harness.quit).not.toHaveBeenCalled(); - }); - - it("quits without showing a hint in direct mode", async () => { - const harness = makeHarness({ mode: "direct" }); - await harness.send(makeInput({})); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([]); - }); - it("honors direct mode when the key is released before its mode read settles", async () => { let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; const harness = makeHarness({ @@ -313,128 +152,6 @@ describe("makeQuitShortcutHandler", () => { expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double press in double-click mode when the first release is unseen", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("keeps the double-press hint visible after key release until the window ends", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(100); - await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - vi.advanceTimersByTime(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("accepts a second full shortcut after the modifier is released and pressed again", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - vi.advanceTimersByTime(100); - - await harness.send(makeInput({ key: "Meta" })); - await harness.send(makeInput({})); - - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { - let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; - const harness = makeHarness({ - getMode: () => - new Promise((resolve) => { - resolveMode = resolve; - }), - }); - await harness.send(makeInput({})); - vi.advanceTimersByTime(100); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(100); - resolveMode?.("double-click"); - await Promise.resolve(); - await Promise.resolve(); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); - vi.advanceTimersByTime(1); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); - }); - - it("treats two slow presses as separate attempts in double-click mode", async () => { - const harness = makeHarness({ mode: "double-click" }); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); - await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); - }); - - it("quits on a quick second press in hold mode", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("quits on a quick second press in hold mode when the first release is unseen", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); - await harness.send(makeInput({})); - - expect(harness.concealWindow).not.toHaveBeenCalled(); - expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - }); - - it("does not count auto-repeat as a second press", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(QUIT_DOUBLE_PRESS_MS - 100); - - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN]); - }); - - it("does not count a released tap after another shortcut interrupts it", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.send(makeInput({ type: "keyUp" })); - await harness.send(makeInput({ key: "c" })); - vi.advanceTimersByTime(100); - await harness.send(makeInput({})); - - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); - }); - - it("cancels the hold when another key interrupts it", async () => { - const harness = makeHarness(); - await harness.send(makeInput({})); - await harness.holdFor(500); - await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - await harness.holdFor(QUIT_HOLD_DURATION_MS); - expect(harness.quit).not.toHaveBeenCalled(); - }); - it("does not count an interrupted press toward a double press", async () => { const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); @@ -443,22 +160,4 @@ describe("makeQuitShortcutHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - - it("ignores other shortcuts", async () => { - const harness = makeHarness(); - await harness.send(makeInput({ key: "w" })); - await harness.send(makeInput({ shift: true })); - await harness.send(makeInput({ meta: false })); - expect(harness.preventDefault).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([]); - }); - - it("uses control on non-mac platforms", async () => { - const harness = makeHarness({ platform: "linux" }); - await harness.send(makeInput({ meta: false, control: true })); - expect(harness.preventDefault).toHaveBeenCalledTimes(1); - await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); - await harness.send(makeInput({ type: "keyUp", meta: false, control: true })); - expect(harness.quit).toHaveBeenCalledTimes(1); - }); }); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index eb0b3141c47a..b87ae9ceab29 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -9,7 +9,7 @@ export const QUIT_DOUBLE_PRESS_MS = 500; // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after // repeats stop so they cannot reach the next app. -export const QUIT_HOLD_RELEASE_GRACE_MS = 600; +const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. const QUIT_HOLD_REPEAT_CADENCE_MULTIPLIER = 2; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index e6dcb7bd7133..8468a9538630 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -84,29 +84,6 @@ const layerNet = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { - it.effect("does not discover or start WSL when local execution is disabled", () => - Effect.gen(function* () { - const backend = yield* DesktopWslBackend.DesktopWslBackend; - yield* backend.reconcile; - }).pipe( - Effect.provide( - DesktopWslBackend.layer.pipe( - Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), - Layer.provide(layerBackendConfiguration), - Layer.provide(layerServerExposure), - Layer.provide(layerNet), - Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), - Layer.provide( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - localEnvironmentEnabled: false, - wslBackendEnabled: true, - }), - ), - ), - ), - ), - ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; let registeredWsl: DesktopBackendPool.DesktopBackendInstance | undefined; @@ -214,47 +191,4 @@ describe("DesktopWslBackend", () => { ), ); }); - - it.effect("retries an unchanged WSL instance when it is idle after failed preflight", () => { - let startCount = 0; - const primary = makeStubInstance({ - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: "Windows", - snapshot: primarySnapshot, - }); - const wsl = makeStubInstance({ - id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), - label: "WSL (Ubuntu)", - snapshot: idleSnapshot, - start: Effect.sync(() => { - startCount += 1; - }), - }); - - return Effect.gen(function* () { - const backend = yield* DesktopWslBackend.DesktopWslBackend; - - yield* backend.reconcile; - - assert.equal(startCount, 1); - }).pipe( - Effect.provide( - DesktopWslBackend.layer.pipe( - Layer.provideMerge(DesktopBackendPool.layerTest([primary, wsl])), - Layer.provideMerge(layerBackendConfiguration), - Layer.provideMerge(layerServerExposure), - Layer.provideMerge(layerNet), - Layer.provideMerge( - DesktopAppSettings.layerTest({ - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - wslBackendEnabled: true, - wslDistro: "Ubuntu", - wslOnly: false, - }), - ), - Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), - ), - ), - ); - }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 670dcb898405..711733cb36b5 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -2,40 +2,16 @@ import { describe, it } from "@effect/vitest"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; - -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, buildWslRuntimeProbeScript, - DesktopWslDistroListError, - DesktopWslEnvironment, - formatMissingToolsReason, - layer as wslEnvironmentLayer, - parseNodePath, - parseNodeVersion, parseResolvedPath, - parseToolchainReport, parseWslRuntimeRoot, - probeWslDistros, } from "./DesktopWslEnvironment.ts"; -const encoder = new TextEncoder(); - // The install script only fails the way this file cares about when a real shell // runs it, so find one that has the tools it needs: bash directly on Linux, and // the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere @@ -102,166 +78,6 @@ const readField = (stdout: string, field: string) => { const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; -const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => - ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: - result.exitCode === undefined - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), - isRunning: Effect.succeed(result.exitCode === undefined), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout ?? "")), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ); - -describe("probeWslDistros", () => { - it.effect("preserves a successful empty distro list", () => - Effect.gen(function* () { - const distros = yield* probeWslDistros; - expect(distros).toEqual([]); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ stdout: "", exitCode: 0 }), - ), - ), - ); - - it.effect("fails when the distro-list command exits unsuccessfully", () => - Effect.gen(function* () { - const error = yield* probeWslDistros.pipe(Effect.flip); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("exited with code 1"); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeDistroListSpawner({ exitCode: 1 }), - ), - ), - ); - - it.effect("fails when the distro-list command times out", () => { - const layer = Layer.merge( - TestClock.layer(), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), - ); - return Effect.gen(function* () { - const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.seconds(8)); - const error = yield* Fiber.join(fiber); - expect(error).toBeInstanceOf(DesktopWslDistroListError); - expect(error.message).toContain("timed out"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("WSL command deadlines", () => { - const makeControlledEnvironment = Effect.gen(function* () { - const started = yield* Deferred.make(); - const exited = yield* Deferred.make(); - const released = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make(() => - Effect.acquireRelease( - Deferred.succeed(started, undefined).pipe( - Effect.as( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.await(exited), - isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ), - () => Deferred.succeed(released, undefined), - ), - ); - const environment = yield* DesktopWslEnvironment.pipe( - Effect.provide( - wslEnvironmentLayer.pipe( - Layer.provide([ - DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop", - homeDirectory: "/home/test", - platform: "win32", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/repo/resources", - runningUnderArm64Translation: false, - }).pipe(Layer.provide([Path.layer, DesktopConfig.layerTest({})])), - FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - ]), - ), - ), - ); - return { environment, started, exited, released }; - }); - - it.effect("waits for a cold distro to finish starting after 15 seconds", () => - Effect.gen(function* () { - const { environment, started, exited, released } = yield* makeControlledEnvironment; - const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(15)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); - yield* Fiber.join(fiber); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); - - it.effect("releases a stalled warmup process at the 30-second deadline", () => - Effect.gen(function* () { - const { environment, started, released } = yield* makeControlledEnvironment; - const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(29)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Fiber.join(fiber); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); - - it.effect("still gives path conversion only 10 seconds and releases its process", () => - Effect.gen(function* () { - const { environment, started, released } = yield* makeControlledEnvironment; - const fiber = yield* environment - .windowsToWslPath("Ubuntu", "C:\\project") - .pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.seconds(9)); - expect(yield* Deferred.isDone(released)).toBe(false); - - yield* TestClock.adjust(Duration.seconds(1)); - expect(yield* Fiber.join(fiber)).toEqual(Option.none()); - expect(yield* Deferred.isDone(released)).toBe(true); - }), - ); -}); - describe("WSL runtime cache", () => { it.each([ [ @@ -276,181 +92,6 @@ describe("WSL runtime cache", () => { expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); expect(script).not.toContain(runtimeId); }); - - it("installs through a temporary directory and only reuses valid completed caches", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); - expect(script).toContain(' [ -f "$ready_marker" ] &&'); - expect(script).toContain(' runtime_entry_runs "$runtime_root" 2>/dev/null &&'); - expect(script).toContain("if runtime_is_ready; then"); - expect(script).not.toContain("bin.mjs"); - expect(script).not.toContain("node-pty"); - expect(script).toContain("trap 'exit 1' HUP INT TERM"); - expect(script).toContain('exec 9> "$runtime_lock"'); - expect(script).toContain("flock -x 9"); - expect(script).not.toContain('rm -rf "$runtime_lock"'); - expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); - expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); - // The release archive wraps everything in one `t3--linux-x64/` - // directory; stripping it puts the executable at `$runtime_root/t3`. - expect(script).toContain( - "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", - ); - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); - expect(script).not.toContain('rm -rf "$runtime_root"'); - - const lockAcquired = script.indexOf("flock -x 9"); - const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(lockAcquired).toBeGreaterThan(-1); - expect(readinessAfterLock).toBeGreaterThan(lockAcquired); - expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); - }); - - it("verifies the archive digest before extracting, and only on a cache miss", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - const expected = "b".repeat(64); - expect(script).toContain( - "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", - ); - expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); - - const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); - const digestChecked = script.indexOf("archive_sha=$(sha256sum"); - const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - const extracted = script.indexOf("tar -xzf"); - expect(digestChecked).toBeGreaterThan(readyShortCircuit); - expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); - expect(extracted).toBeGreaterThan(digestChecked); - }); - - it("moves an in-use runtime aside instead of deleting it under a live backend", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "sha256-" + "c".repeat(64), - "b".repeat(64), - ); - - expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(" [ -d /proc/1 ] || return 0"); - expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); - - // A process's cmdline keeps the pre-rename path, so the question is only - // answerable before the move. - const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); - const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(inUseChecked).toBeLessThan(moved); - - const kept = script.indexOf('touch "$runtime_stale"'); - const deleted = script.indexOf('rm -rf "$runtime_stale"'); - expect(kept).toBeGreaterThan(moved); - expect(deleted).toBeGreaterThan(kept); - }); - - it("re-hashes the executable against the digest the install recorded", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); - expect(script).toContain( - ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', - ); - expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); - expect(script).toContain( - `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, - ); - - const extracted = script.indexOf("tar -xzf"); - const digestRecorded = script.indexOf( - 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', - ); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(digestRecorded).toBeGreaterThan(extracted); - expect(markerWritten).toBeGreaterThan(digestRecorded); - expect(promoted).toBeGreaterThan(markerWritten); - }); - - it("refuses to mark an archive whose executable does not run as ready", () => { - const script = buildWslRuntimeInstallScript( - "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", - "1.2.3-x64", - "b".repeat(64), - ); - - expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - - const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); - const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); - const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); - expect(payloadValidated).toBeGreaterThan(-1); - expect(markerWritten).toBeGreaterThan(payloadValidated); - expect(promoted).toBeGreaterThan(payloadValidated); - }); - - it("parses only absolute Linux runtime paths", () => { - expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( - "/home/josh/.t3/wsl-runtime/1.2.3-x64", - ); - expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); - expect(parseWslRuntimeRoot("noise\n")).toBeNull(); - }); - - it("prunes completed runtimes except the current and newest previous cache", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); - expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); - expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); - expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); - expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); - expect(script).toContain('rm -rf -- "$candidate"'); - }); - - it("never deletes a runtime another backend is running from", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); - - expect(script).toContain("[ -d /proc/1 ] || exit 0"); - - const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); - const removed = script.indexOf('rm -rf -- "$candidate"'); - expect(inUseChecked).toBeGreaterThan(-1); - expect(removed).toBeGreaterThan(inUseChecked); - }); - - it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { - const script = buildWslRuntimePruneScript("1.2.3/x64"); - - expect(script).toContain( - 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', - ); - expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); - }); - - it("invalidates a cache by dropping its ready marker, not the tree", () => { - const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); - - expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); - expect(script).not.toContain("rm -rf"); - }); }); describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { @@ -546,16 +187,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(provider.stdout).toBe("linux-node-provider\n"); }); - it("keeps standalone runtime readiness independent of Node availability", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - - const probe = probeFixture(fixture); - - expect(probe.status, probe.stderr).toBe(0); - expect(parseResolvedPath(probe.stdout)).not.toBeNull(); - }); - it("keeps the inherited PATH when bash is unavailable", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -747,53 +378,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); }); - it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', - 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', - 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', - `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, - `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, - 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript("sha256-current"), - `test ! -e ${sh(fixture.runtimeRoot)}`, - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - - it("removes an aged stale tree after replacing an active unready cache", () => { - const fixture = createFixture(); - expect(fixture.install().status).toBe(0); - const result = runShell( - [ - "set -eu", - `runtime_root=${sh(fixture.runtimeRoot)}`, - `runtime_parent=${sh(fixture.runtimeParent)}`, - 'rm "$runtime_root/.t3code-wsl-runtime-ready"', - holdRuntimeBusy('"$runtime_root/t3"'), - fixture.installScript(), - 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', - 'test -n "$stale"', - 'touch -d "180 minutes ago" "$stale"', - `HOME=${sh(`${fixture.work}/home`)}`, - "export HOME", - buildWslRuntimePruneScript(fixture.runtimeId), - 'test ! -e "$stale"', - ].join("\n"), - ); - - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - }); - it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ @@ -833,78 +417,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }); }); -describe("parseToolchainReport", () => { - it("returns no missing tools and no node version on empty output", () => { - expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); - }); - - it("collects all missing: lines", () => { - const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["make", "g++"], - nodeVersion: "24.10.0", - }); - }); - - it("ignores blank lines and trims whitespace", () => { - const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); - expect(parseToolchainReport(stdout)).toEqual({ - missingTools: ["python3"], - nodeVersion: "v22.16.0", - }); - }); - - it("returns null node version when value after prefix is empty", () => { - expect(parseToolchainReport("nodeVersion:")).toEqual({ - missingTools: [], - nodeVersion: null, - }); - }); -}); - -describe("parseNodePath", () => { - it("extracts the absolute node path from a nodePath: line", () => { - const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; - expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); - }); - - it("returns null when node was not found (empty value after prefix)", () => { - expect(parseNodePath("nodePath:")).toBeNull(); - }); - - it("returns null when there is no nodePath line at all", () => { - expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); - expect(parseNodePath(stdout)).toBe("/usr/bin/node"); - }); -}); - -describe("parseNodeVersion", () => { - it("extracts the node version from a nodeVersion: line", () => { - expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); - }); - - it("returns null when the version value is empty", () => { - expect(parseNodeVersion("nodeVersion:")).toBeNull(); - }); - - it("returns null when there is no nodeVersion line at all", () => { - expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); - }); - - it("ignores surrounding noise and trims whitespace", () => { - const stdout = [ - "some preamble noise", - " nodeVersion:22.16.0 ", - "nodePath:/usr/bin/node", - ].join("\n"); - expect(parseNodeVersion(stdout)).toBe("22.16.0"); - }); -}); - describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; @@ -918,52 +430,4 @@ describe("parseResolvedPath", () => { "/usr/local/bin:/usr/bin", ); }); - - it("returns null when the resolved PATH is absent or empty", () => { - expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); - expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); - }); -}); - -describe("formatMissingToolsReason", () => { - it("returns null when everything is present and node is in range", () => { - expect( - formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), - ).toBeNull(); - }); - - it("returns null when range is not specified and tools are present", () => { - expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); - }); - - it("flags missing node first", () => { - const reason = formatMissingToolsReason( - { missingTools: ["node", "make"], nodeVersion: null }, - "^24.10", - ); - expect(reason).toContain("node"); - expect(reason).toContain("^24.10"); - expect(reason).toContain("make"); - expect(reason).toContain("nvm"); - }); - - it("flags an out-of-range node version with the actual version surfaced", () => { - const reason = formatMissingToolsReason( - { missingTools: [], nodeVersion: "20.0.0" }, - "^24.10 || ^22.16", - ); - expect(reason).toContain("node 20.0.0"); - expect(reason).toContain("requires ^24.10 || ^22.16"); - }); - - it("flags missing build tools without node when node is fine", () => { - const reason = formatMissingToolsReason( - { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, - "^24.10", - ); - expect(reason).toContain("g++"); - expect(reason).toContain("python3"); - expect(reason).toContain("build-essential"); - expect(reason).not.toContain("nvm"); - }); }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 25877552671a..ca5b89c9d49f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -513,7 +513,7 @@ export interface ToolchainReport { readonly nodeVersion: string | null; } -export const parseToolchainReport = (stdout: string): ToolchainReport => { +const parseToolchainReport = (stdout: string): ToolchainReport => { const lines = stdout .split("\n") .map((line) => line.trim()) @@ -528,7 +528,7 @@ export const parseToolchainReport = (stdout: string): ToolchainReport => { return { missingTools, nodeVersion }; }; -export const parseNodePath = (stdout: string): string | null => { +const parseNodePath = (stdout: string): string | null => { const path = stdout .split("\n") .map((line) => line.trim()) @@ -538,7 +538,7 @@ export const parseNodePath = (stdout: string): string | null => { return path ?? null; }; -export const parseNodeVersion = (stdout: string): string | null => { +const parseNodeVersion = (stdout: string): string | null => { const version = stdout .split("\n") .map((line) => line.trim()) @@ -558,7 +558,7 @@ export const parseResolvedPath = (stdout: string): string | null => { return resolvedPath.length > 0 ? resolvedPath : null; }; -export const formatMissingToolsReason = ( +const formatMissingToolsReason = ( report: ToolchainReport, requiredRange: string | null, ): string | null => { @@ -888,7 +888,7 @@ const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")( }); }); -export const probeWslDistros: Effect.Effect< +const probeWslDistros: Effect.Effect< readonly WslDistro[], DesktopWslDistroListError, ChildProcessSpawner.ChildProcessSpawner diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 0faa1b29472a..f267b7899c48 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -105,20 +105,6 @@ describe("DesktopWslServerTree", () => { }), ); - it.effect("returns the server root unchanged when it is a plain directory (dev)", () => - withTempDir((tempDir) => - Effect.gen(function* () { - const result = yield* ensureWith({ - baseDir: tempDir, - resourcesPath: tempDir, - isPackaged: false, - }); - assert.isTrue(result.ok); - assert.isFalse(result.ok && result.root.endsWith(".asar")); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("extracts an archive root into a version-keyed state directory", () => withTempDir((tempDir) => Effect.gen(function* () { @@ -292,32 +278,6 @@ describe("DesktopWslServerTree", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("removes the legacy Windows extraction tree without preparing a fallback", () => - withTempDir((tempDir) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const resourcesPath = path.join(tempDir, "resources"); - const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); - yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true }); - yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old"); - - yield* Effect.gen(function* () { - const tree = yield* DesktopWslServerTree.DesktopWslServerTree; - yield* tree.cleanupLegacy; - }).pipe( - Effect.provide( - DesktopWslServerTree.layer.pipe( - Layer.provideMerge(layerEnvironment({ baseDir: tempDir, resourcesPath })), - ), - ), - ); - - assert.isFalse(yield* fileSystem.exists(treeRoot)); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () => withTempDir((tempDir) => Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index dd750164b381..0a814229665d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -30,15 +30,6 @@ describe("parseWslDistroList", () => { ]); }); - it("returns empty array for empty buffer", () => { - expect(parseWslDistroList(Buffer.alloc(0))).toEqual([]); - }); - - it("returns empty array for header-only output", () => { - const output = makeUtf16LeBuffer(" NAME STATE VERSION\r\n"); - expect(parseWslDistroList(output)).toEqual([]); - }); - it("skips malformed lines", () => { const output = makeUtf16LeBuffer( [ @@ -56,16 +47,6 @@ describe("parseWslDistroList", () => { ]); }); - it("handles output without BOM", () => { - const text = [ - " NAME STATE VERSION", - "* Ubuntu Running 2", - ].join("\r\n"); - const output = Buffer.from(text, "utf16le"); - const distros = parseWslDistroList(output); - expect(distros).toEqual([{ name: "Ubuntu", isDefault: true, version: 2 }]); - }); - it("handles UTF-8 output", () => { const text = [ " NAME STATE VERSION", @@ -82,18 +63,6 @@ describe("extractDistroFromUncPath", () => { "Ubuntu-22.04", ); }); - - it("extracts the distro from the legacy \\\\wsl$ UNC paths", () => { - expect(extractDistroFromUncPath("\\\\wsl$\\Debian\\home\\josh")).toBe("Debian"); - }); - - it("returns null for non-UNC Windows paths", () => { - expect(extractDistroFromUncPath("C:\\Users\\Josh\\project")).toBeNull(); - }); - - it("returns null when the segment is not a valid distro name", () => { - expect(extractDistroFromUncPath("\\\\wsl.localhost\\bad name!\\home")).toBeNull(); - }); }); describe("wslUncPathToLinuxPath", () => { @@ -118,12 +87,6 @@ describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; - it("uses WSL home when no initial path is provided", () => { - expect(resolveWslPickFolderDefaultPath(undefined, config, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - it("maps Linux initial paths to WSL UNC paths", () => { expect( resolveWslPickFolderDefaultPath({ initialPath: "/home/josh/project" }, config, distros), @@ -136,36 +99,14 @@ describe("resolveWslPickFolderDefaultPath", () => { ).toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project"); }); - it("resolves bare ~ to the user's home dir when known", () => { - expect( - resolveWslPickFolderDefaultPath({ initialPath: "~" }, config, distros, "/home/josh"), - ).toBe("\\\\wsl.localhost\\Debian\\home\\josh"); - }); - it("falls back to /home parent when the user's home dir isn't known", () => { expect(resolveWslPickFolderDefaultPath({ initialPath: "~/project" }, config, distros)).toBe( "\\\\wsl.localhost\\Debian\\home\\project", ); }); - - it("preserves existing UNC initial paths", () => { - expect( - resolveWslPickFolderDefaultPath( - { initialPath: "\\\\wsl.localhost\\Ubuntu\\home\\josh" }, - config, - distros, - ), - ).toBe("\\\\wsl.localhost\\Ubuntu\\home\\josh"); - }); }); describe("isValidDistroName", () => { - it("accepts common distro names", () => { - for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(isValidDistroName(name)).toBe(true); - } - }); - it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { expect(isValidDistroName(name)).toBe(false); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 893d6f8e076f..c0a77e9a48b7 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -43,6 +43,11 @@ export default defineConfig({ dependsOn: ["t3#build"], cache: false, }, + // tsc-rs rewrites the tsbuildinfo it reads, which keeps a task out of the cache. + typecheck: { + command: "tsc-rs --noEmit", + cache: { input: [{ auto: true }, "!*.tsbuildinfo"] }, + }, }, }, pack: [ @@ -145,6 +150,9 @@ export default defineConfig({ // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, - setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], }, }); diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 5ba3da4fba10..15a8a8f91633 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,5 +1,11 @@ import { defineConfig } from "astro/config"; +// Astro loads env with an empty prefix, and under `vp run` Vite+'s loadEnv then reports every +// variable to the task cache. Any env difference (TERM, VP_RAW_SUBCOMMAND, an agent's session +// vars) would rerun `astro check`. Without the runner client, loadEnv reads process.env directly; +// file tracking does not use this client. +delete process.env.VP_RUN_NODE_CLIENT_PATH; + export default defineConfig({ site: "https://t3.codes", server: { diff --git a/apps/marketing/package.json b/apps/marketing/package.json index ca0582be6009..36dc282e89b4 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -7,9 +7,7 @@ "stage:install-scripts": "node scripts/stage-install-scripts.mjs", "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", - "preview": "astro preview", - "typecheck": "astro check", - "test": "vp test run" + "preview": "astro preview" }, "dependencies": { "@t3tools/shared": "workspace:*", diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts deleted file mode 100644 index 0a393b4758a0..000000000000 --- a/apps/marketing/src/lib/homeMotion.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { startHomeMotion } from "./homeMotion"; - -class ElementStub extends EventTarget { - properties = new Map(); - style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; - children: ElementStub[] = []; - querySelectorAll = () => this.children; - getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); -} - -let observers: ObserverStub[] = []; -class ObserverStub { - constructor(private readonly callback: IntersectionObserverCallback) { - observers.push(this); - } - observe = vi.fn(); - disconnect = vi.fn(); - report(target: ElementStub, isIntersecting: boolean) { - this.callback( - [{ target, isIntersecting } as unknown as IntersectionObserverEntry], - this as unknown as IntersectionObserver, - ); - } -} - -let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); -let reduced = Object.assign(new EventTarget(), { matches: false }); -let fine = Object.assign(new EventTarget(), { matches: true }); -let frames = new Map(); -let dispose: (() => void) | undefined; - -beforeEach(() => { - observers = []; - frames = new Map(); - page = Object.assign(new EventTarget(), { visibilityState: "visible" }); - reduced = Object.assign(new EventTarget(), { matches: false }); - fine = Object.assign(new EventTarget(), { matches: true }); - vi.stubGlobal("document", page); - vi.stubGlobal( - "window", - Object.assign(new EventTarget(), { - matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), - }), - ); - vi.stubGlobal("IntersectionObserver", ObserverStub); - let frameId = 0; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - frames.set(++frameId, callback); - return frameId; - }); - vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); -}); - -afterEach(() => { - dispose?.(); - dispose = undefined; - vi.unstubAllGlobals(); -}); - -function fixture() { - const hero = new ElementStub(); - const field = new ElementStub(); - const mark = new ElementStub(); - const otherMark = new ElementStub(); - field.children = [mark, otherMark]; - const track = new ElementStub(); - const caret = new ElementStub(); - dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters< - typeof startHomeMotion - >[0]); - return { hero, field, mark, otherMark, track, caret, observer: observers[0]! }; -} - -function movePointer(hero: ElementStub, x = 400, y = 600) { - hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); -} - -describe("homepage motion", () => { - it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { - const { hero, field, mark, otherMark, track, caret, observer } = fixture(); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - observer.report(mark, true); - observer.report(track, true); - observer.report(caret, true); - expect(mark.properties.get("--home-motion-state")).toBe("running"); - expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("running"); - expect(caret.properties.get("--home-motion-state")).toBe("running"); - - movePointer(hero, 100, 100); - movePointer(hero); - expect(frames.size).toBe(1); - expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); - const [id, callback] = [...frames][0]!; - frames.delete(id); - callback(0); - expect(field.properties.get("--px")).toBe("18.0px"); - expect(field.properties.get("--py")).toBe("14.0px"); - - movePointer(hero); - page.visibilityState = "hidden"; - page.dispatchEvent(new Event("visibilitychange")); - expect(frames.size).toBe(0); - expect(field.properties.get("--px")).toBe("0px"); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - expect(caret.properties.get("--home-motion-state")).toBe("paused"); - page.visibilityState = "visible"; - page.dispatchEvent(new Event("visibilitychange")); - reduced.matches = true; - reduced.dispatchEvent(new Event("change")); - movePointer(hero); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - reduced.matches = false; - fine.matches = false; - reduced.dispatchEvent(new Event("change")); - movePointer(hero); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("running"); - }); - - it("cancels pending work and ignores events after cleanup", () => { - const { hero, mark, track, observer } = fixture(); - observer.report(mark, true); - observer.report(track, true); - movePointer(hero); - dispose?.(); - observer.report(mark, true); - movePointer(hero); - reduced.dispatchEvent(new Event("change")); - expect(observer.disconnect).toHaveBeenCalledTimes(1); - expect(frames.size).toBe(0); - expect(mark.properties.get("--home-motion-state")).toBe("paused"); - expect(track.properties.get("--home-motion-state")).toBe("paused"); - }); -}); diff --git a/apps/marketing/vite.config.ts b/apps/marketing/vite.config.ts new file mode 100644 index 000000000000..f927357c438d --- /dev/null +++ b/apps/marketing/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite-plus"; + +// Astro reads astro.config.mjs. This file only configures `vp run` tasks. +export default defineConfig({ + run: { + tasks: { + // `astro check` regenerates .astro/*.d.ts and then reads them, which + // keeps a task out of the cache. Its real inputs are tracked as usual. + typecheck: { + command: "astro check", + cache: { input: [{ auto: true }, "!.astro/**"] }, + }, + }, + }, +}); diff --git a/apps/mobile/assets/context-chip-icons/circle-dashed.png b/apps/mobile/assets/context-chip-icons/circle-dashed.png new file mode 100644 index 000000000000..72985d77921d Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/circle-dashed.png differ diff --git a/apps/mobile/assets/context-chip-icons/circle-dot.png b/apps/mobile/assets/context-chip-icons/circle-dot.png new file mode 100644 index 000000000000..688e4fdf89d0 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/circle-dot.png differ diff --git a/apps/mobile/assets/context-chip-icons/cpu.png b/apps/mobile/assets/context-chip-icons/cpu.png new file mode 100644 index 000000000000..09b4870760ce Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/cpu.png differ diff --git a/apps/mobile/assets/context-chip-icons/film.png b/apps/mobile/assets/context-chip-icons/film.png new file mode 100644 index 000000000000..399752993e3a Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/film.png differ diff --git a/apps/mobile/assets/context-chip-icons/folder-git-2.png b/apps/mobile/assets/context-chip-icons/folder-git-2.png new file mode 100644 index 000000000000..bb8b4e4c0b30 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/folder-git-2.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-branch.png b/apps/mobile/assets/context-chip-icons/git-branch.png new file mode 100644 index 000000000000..1a6d1e1c4810 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-branch.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-commit-horizontal.png b/apps/mobile/assets/context-chip-icons/git-commit-horizontal.png new file mode 100644 index 000000000000..5efa3af149ed Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-commit-horizontal.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-merge.png b/apps/mobile/assets/context-chip-icons/git-merge.png new file mode 100644 index 000000000000..06c049331e5d Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-merge.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png b/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png new file mode 100644 index 000000000000..344120c4cc65 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-arrow.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png b/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png new file mode 100644 index 000000000000..777b09536393 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-closed.png differ diff --git a/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png b/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png new file mode 100644 index 000000000000..b1490a873e48 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/git-pull-request-draft.png differ diff --git a/apps/mobile/assets/context-chip-icons/hash.png b/apps/mobile/assets/context-chip-icons/hash.png new file mode 100644 index 000000000000..ddf585947e97 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/hash.png differ diff --git a/apps/mobile/assets/context-chip-icons/image.png b/apps/mobile/assets/context-chip-icons/image.png new file mode 100644 index 000000000000..ac0ef9f7b44f Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/image.png differ diff --git a/apps/mobile/assets/context-chip-icons/laptop.png b/apps/mobile/assets/context-chip-icons/laptop.png new file mode 100644 index 000000000000..1bceea4ca8dc Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/laptop.png differ diff --git a/apps/mobile/assets/context-chip-icons/link.png b/apps/mobile/assets/context-chip-icons/link.png new file mode 100644 index 000000000000..e9f068ff63f5 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/link.png differ diff --git a/apps/mobile/assets/context-chip-icons/message-circle.png b/apps/mobile/assets/context-chip-icons/message-circle.png new file mode 100644 index 000000000000..9f409bf70c25 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/message-circle.png differ diff --git a/apps/mobile/assets/context-chip-icons/messages-square.png b/apps/mobile/assets/context-chip-icons/messages-square.png new file mode 100644 index 000000000000..73f5da793575 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/messages-square.png differ diff --git a/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png b/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png new file mode 100644 index 000000000000..64d8e727fb56 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/mouse-pointer-click.png differ diff --git a/apps/mobile/assets/context-chip-icons/skill.png b/apps/mobile/assets/context-chip-icons/skill.png new file mode 100644 index 000000000000..60e960d23233 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/skill.png differ diff --git a/apps/mobile/assets/context-chip-icons/tag.png b/apps/mobile/assets/context-chip-icons/tag.png new file mode 100644 index 000000000000..4c586a41e321 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/tag.png differ diff --git a/apps/mobile/assets/context-chip-icons/terminal.png b/apps/mobile/assets/context-chip-icons/terminal.png new file mode 100644 index 000000000000..188638a9c2f7 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/terminal.png differ diff --git a/apps/mobile/assets/context-chip-icons/ticket.png b/apps/mobile/assets/context-chip-icons/ticket.png new file mode 100644 index 000000000000..656b5d39835e Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/ticket.png differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz deleted file mode 100644 index 33a1dcca0721..000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz new file mode 100644 index 000000000000..581307cf91be Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt new file mode 100644 index 000000000000..5b466062932a --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ChipIcons.kt @@ -0,0 +1,60 @@ +package expo.modules.t3composereditor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Handler +import android.os.Looper +import java.net.URL +import java.util.concurrent.Executors + +internal object T3ChipIcons { + private val images = HashMap() + private val pending = HashMap Unit>>() + private val downloads = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + + fun cached(uri: String): Bitmap? = images[uri] + + fun load(context: Context, uri: String, onLoaded: (String) -> Unit): Bitmap? = + images[uri] ?: when { + uri.isEmpty() -> null + uri.startsWith("http://") || uri.startsWith("https://") -> { + download(uri, onLoaded) + null + } + else -> decodeLocal(context, uri)?.also { images[uri] = it } + } + + private fun download(uri: String, onLoaded: (String) -> Unit) { + val waiting = pending[uri] + if (waiting != null) { + waiting.add(onLoaded) + return + } + pending[uri] = mutableSetOf(onLoaded) + downloads.execute { + val bitmap = runCatching { + URL(uri).openStream().use(BitmapFactory::decodeStream) + }.getOrNull() + main.post { + val callbacks = pending.remove(uri).orEmpty() + if (bitmap != null) { + images[uri] = bitmap + callbacks.forEach { it(uri) } + } + } + } + } + + private fun decodeLocal(context: Context, uri: String): Bitmap? = runCatching { + when { + uri.startsWith("/") -> BitmapFactory.decodeFile(uri) + uri.startsWith("file:") -> Uri.parse(uri).path?.let(BitmapFactory::decodeFile) + else -> context.resources.getIdentifier(uri, "drawable", context.packageName) + .takeIf { it != 0 } + ?.let { BitmapFactory.decodeResource(context.resources, it) } + } + }.getOrNull() +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 1d57632eee5f..0b44599016b1 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -57,10 +57,24 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private var applyingNativeValue = false private var desiredLineHeightPx = 0 private var lastContentHeight = 0 + private var lastContentWidth = 0 private var contentInsetVertical = 0 private var tokensJson = "[]" private var tokens: List = emptyList() - private var chipTheme = ComposerChipTheme.default() + private val redrawChipsWithIcon: (String) -> Unit = { uri -> + editor.text?.let { editable -> + editable.getSpans(0, editable.length, ComposerChipSpan::class.java) + .filter { it.iconUri == uri } + .forEach { + editable.setSpan( + it, + editable.getSpanStart(it), + editable.getSpanEnd(it), + editable.getSpanFlags(it) + ) + } + } + } private var autoCorrect = true private var spellCheck = true private var nativeEventCount = 0 @@ -240,17 +254,6 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } else { applySelectionTheme(parseColor(theme.optString("selection"), editor.currentTextColor)) } - chipTheme = ComposerChipTheme( - chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), - chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), - chipText = parseColor(theme.optString("chipText"), chipTheme.chipText), - skillBackground = parseColor( - theme.optString("skillBackground"), - chipTheme.skillBackground, - ), - skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder), - skillText = parseColor(theme.optString("skillText"), chipTheme.skillText), - ) applyTokenSpans() } catch (_: Exception) { } @@ -424,12 +427,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } private fun emitContentSizeIfNeeded() { - val height = editor.layout?.height ?: editor.measuredHeight - val contentHeight = height + contentInsetVertical * 2 - if (contentHeight == lastContentHeight) return + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters. Trailing whitespace hangs past a line without + // wrapping it, so getLineMax leaves it out. + val layout = editor.layout + val contentHeight = layout?.height ?: editor.measuredHeight + val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0 + if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return lastContentHeight = contentHeight + lastContentWidth = contentWidth + val density = resources.displayMetrics.density onComposerContentSizeChange( - mapOf("height" to contentHeight / resources.displayMetrics.density), + mapOf("width" to contentWidth / density, "height" to contentHeight / density), ) } @@ -444,20 +454,18 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach val expectedSource = editable.substring(token.start, token.end) if (expectedSource != token.source) return@forEach + T3ChipIcons.load(context, token.iconUri, redrawChipsWithIcon) editable.setSpan( ComposerChipSpan( T3ContextChip( content = T3ContextChip.Content( label = token.label, - symbol = token.symbol, - detail = token.detail - ), - fontSize = editor.textSize * 0.8f, - colors = T3ContextChip.Colors( - accent = T3ContextChip.color(token.accent, chipTheme.chipText), - foreground = chipTheme.chipText, - border = chipTheme.chipBorder + detail = token.detail, + iconUri = token.iconUri, + tintsIcon = token.tintsIcon ), + fontSize = editor.textSize, + color = T3ContextChip.color(token.accent, editor.currentTextColor), maximumWidth = ( editor.width.takeIf { it > 0 @@ -500,34 +508,17 @@ private data class ComposerToken( val label: String, val detail: String, val accent: String, - val symbol: String, + val iconUri: String, + val tintsIcon: Boolean, val start: Int, val end: Int ) -private data class ComposerChipTheme( - val chipBackground: Int, - val chipBorder: Int, - val chipText: Int, - val skillBackground: Int, - val skillBorder: Int, - val skillText: Int -) { - companion object { - fun default() = ComposerChipTheme( - chipBackground = Color.rgb(238, 240, 243), - chipBorder = Color.rgb(210, 214, 220), - chipText = Color.rgb(35, 39, 45), - skillBackground = Color.rgb(233, 239, 255), - skillBorder = Color.rgb(185, 200, 245), - skillText = Color.rgb(45, 72, 155), - ) - } -} - private class ComposerChipSpan( private val chip: T3ContextChip ) : ReplacementSpan() { + val iconUri get() = chip.iconUri + override fun getSize( paint: Paint, text: CharSequence, @@ -573,7 +564,8 @@ private fun parseTokens(value: String): List = try { label = token.optString("label"), detail = token.optString("detail"), accent = token.optString("accent"), - symbol = token.optString("symbol", "doc"), + iconUri = if (token.isNull("iconUri")) "" else token.optString("iconUri"), + tintsIcon = token.optBoolean("tintsIcon"), start = token.optInt("start"), end = token.optInt("end"), ) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt index d66708336d44..d2b7b9cb9e46 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -3,7 +3,8 @@ package expo.modules.t3composereditor import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter import android.graphics.RectF import android.graphics.Typeface import android.text.TextPaint @@ -11,24 +12,25 @@ import android.text.TextUtils import kotlin.math.ceil import kotlin.math.min -/** Shared by editable spans and inline chat images so their metrics and colors agree. */ class T3ContextChip( content: Content, fontSize: Float, - colors: Colors, + private val color: Int, maximumWidth: Float, - private val density: Float + density: Float ) { /** What the chip says: its name, the size beside it, and the glyph that leads it. */ - data class Content(val label: String, val symbol: String, val detail: String = "") - - data class Colors(val accent: Int, val foreground: Int, val border: Int) - - private val symbol = content.symbol + data class Content( + val label: String, + val detail: String = "", + val iconUri: String = "", + val tintsIcon: Boolean = false + ) private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + color = this@T3ContextChip.color } private val em = fontSize @@ -37,72 +39,47 @@ class T3ContextChip( private val detailPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = fontSize * 0.84f typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + color = this@T3ContextChip.color } private val detailText = if (content.detail.isEmpty()) "" else " ${content.detail}" private val detailWidth = if (detailText.isEmpty()) 0f else detailPaint.measureText(detailText) - // The border is stroked, and a stroke straddles the path it follows, so the box has to - // reserve a stroke on every side it insets. Reserving it once left the right edge a - // stroke short, and antialiasing shaved that curve flat against the span bounds. - private val strokeWidth = density - private val inset = strokeWidth * 2f + val iconUri = content.iconUri + private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + if (content.tintsIcon) { + colorFilter = + PorterDuffColorFilter(this@T3ContextChip.color, PorterDuff.Mode.SRC_IN) + } + } + private val padding = density * 5 + private val iconSlot = if (iconUri.isEmpty()) 0f else em * 1.25f val width = ceil( min( maximumWidth.coerceAtLeast(em * 3), - paint.measureText(content.label) + detailWidth + em * 2.5f + inset + paint.measureText(content.label) + detailWidth + iconSlot + padding * 2 ) ) - val height = ceil(em * 1.41f + inset) + val height = ceil(paint.fontMetrics.descent - paint.fontMetrics.ascent) private val text = TextUtils.ellipsize( content.label, paint, - (width - em * 2.5f - inset - detailWidth).coerceAtLeast(0f), + (width - iconSlot - padding * 2 - detailWidth).coerceAtLeast(0f), TextUtils.TruncateAt.MIDDLE ).toString() - private val fill = Color.argb( - 28, - Color.red(colors.accent), - Color.green(colors.accent), - Color.blue(colors.accent) - ) - private val textColor = blend(colors.accent, colors.foreground, 0.22f) - private val borderColor = blend(colors.accent, colors.border, 0.34f) - - // Half a stroke keeps the border inside the box; the rest of the reserved margin is - // slack, so an antialiased edge fades out before it reaches the span bounds. - private val shape = - RectF(inset / 2f, inset / 2f, width - inset / 2f, height - inset / 2f) - private val icon = iconPath(symbol) fun draw(canvas: Canvas, x: Float, y: Float) { canvas.save() canvas.translate(x, y) - paint.style = Paint.Style.FILL - paint.color = fill - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.style = Paint.Style.STROKE - paint.strokeWidth = strokeWidth - paint.color = borderColor - canvas.drawRoundRect(shape, em / 2, em / 2, paint) - paint.color = textColor - canvas.save() - val iconSize = em * 1.17f - canvas.translate(em / 2 + inset / 2f, (height - iconSize) / 2) - canvas.scale(iconSize / 24, iconSize / 24) - paint.strokeWidth = 1.7f - paint.strokeJoin = Paint.Join.ROUND - paint.strokeCap = Paint.Cap.ROUND - canvas.drawPath(icon, paint) - canvas.restore() - paint.style = Paint.Style.FILL - val metrics = paint.fontMetrics - val baseline = (height - metrics.descent - metrics.ascent) / 2 - canvas.drawText(text, em * 2 + inset / 2f, baseline, paint) + T3ChipIcons.cached(iconUri)?.let { + val top = (height - em) / 2 + canvas.drawBitmap(it, null, RectF(padding, top, padding + em, top + em), iconPaint) + } + val baseline = -paint.fontMetrics.ascent + canvas.drawText(text, padding + iconSlot, baseline, paint) if (detailText.isNotEmpty()) { - detailPaint.color = paint.color canvas.drawText( detailText, - em * 2 + inset / 2f + paint.measureText(text), + padding + iconSlot + paint.measureText(text), baseline, detailPaint ) @@ -123,90 +100,5 @@ class T3ContextChip( } Color.parseColor(androidColor) }.getOrDefault(fallback) - - private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( - (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), - (Color.green(accent) * weight + Color.green(base) * (1 - weight)).toInt(), - (Color.blue(accent) * weight + Color.blue(base) * (1 - weight)).toInt(), - ) - - private fun iconPath(symbol: String) = Path().apply { - fun line(vararg points: Float) { - moveTo(points[0], points[1]) - for (index in 2 until points.size step 2) lineTo(points[index], points[index + 1]) - } - when (symbol) { - "cube" -> { - line(12f, 2f, 21f, 7f, 21f, 17f, 12f, 22f, 3f, 17f, 3f, 7f, 12f, 2f) - line(3f, 7f, 12f, 12f, 21f, 7f) - line(12f, 12f, 12f, 22f) - } - // lucide `git-pull-request`, the glyph web draws: two nodes, an elbow, and a stem. - "git-pull-request" -> { - addCircle(18f, 18f, 3f, Path.Direction.CW) - addCircle(6f, 6f, 3f, Path.Direction.CW) - moveTo(13f, 6f) - lineTo(16f, 6f) - cubicTo(17.1f, 6f, 18f, 6.9f, 18f, 8f) - lineTo(18f, 15f) - line(6f, 9f, 6f, 21f) - } - // lucide `tag`, `git-branch`, and `git-commit-horizontal`, the GitHub ref glyphs web draws. - "tag" -> { - line(2f, 2f, 12f, 2f, 22f, 12f, 12f, 22f, 2f, 12f, 2f, 2f) - addCircle(7.5f, 7.5f, 1.5f, Path.Direction.CW) - } - "arrow.triangle.branch" -> { - line(6f, 3f, 6f, 15f) - addCircle(18f, 6f, 3f, Path.Direction.CW) - addCircle(6f, 18f, 3f, Path.Direction.CW) - moveTo(18f, 9f) - cubicTo(18f, 13.97f, 13.97f, 18f, 9f, 18f) - } - "circle.and.line.horizontal" -> { - addCircle(12f, 12f, 3f, Path.Direction.CW) - line(3f, 12f, 9f, 12f) - line(15f, 12f, 21f, 12f) - } - // lucide `link-2` without its bar: a link that could not be read. - "link" -> { - moveTo(15f, 7f) - lineTo(17f, 7f) - arcTo(12f, 7f, 22f, 17f, -90f, 180f, false) - lineTo(15f, 17f) - moveTo(9f, 17f) - lineTo(7f, 17f) - arcTo(2f, 7f, 12f, 17f, 90f, 180f, false) - lineTo(9f, 7f) - } - "cursorarrow.click" -> { - line(4f, 3f, 19f, 12f, 12f, 14f, 9f, 21f, 4f, 3f) - line(13f, 15f, 18f, 21f) - } - "text.bubble" -> { - line(3f, 4f, 21f, 4f, 21f, 17f, 10f, 17f, 5f, 21f, 5f, 17f, 3f, 17f, 3f, 4f) - line(7f, 8f, 17f, 8f) - line(7f, 12f, 14f, 12f) - } - "terminal", "play.rectangle", "photo" -> { - addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) - when (symbol) { - "terminal" -> { - line(6f, 8f, 10f, 12f, 6f, 16f) - line(13f, 16f, 18f, 16f) - } - "play.rectangle" -> line(9f, 8f, 16f, 12f, 9f, 16f, 9f, 8f) - else -> { - addCircle(8f, 9f, 1.5f, Path.Direction.CW) - line(3f, 18f, 11f, 12f, 15f, 15f, 18f, 12f, 21f, 16f) - } - } - } - else -> { - line(5f, 2f, 14f, 2f, 20f, 8f, 20f, 22f, 5f, 22f, 5f, 2f) - line(14f, 2f, 14f, 8f, 20f, 8f) - } - } - } } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 7a659cc510ae..9a5dae1db3f2 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -7,7 +7,7 @@ private struct ComposerTokenPayload: Decodable { let label: String let iconUri: String? let accent: String? - let symbol: String? + let tintsIcon: Bool? let detail: String? let start: Int let end: Int @@ -29,20 +29,6 @@ private struct ComposerControlledDocumentPayload: Decodable { private struct ComposerThemePayload: Decodable { let text: String let placeholder: String - let chipBackground: String - let chipBorder: String - let chipText: String - let skillBackground: String - let skillBorder: String - let skillText: String - let fileTint: String -} - -private struct ComposerChipStyle { - let tint: UIColor - let backgroundColor: UIColor - let borderColor: UIColor - let textColor: UIColor } private enum ComposerEnterBehavior: String { @@ -446,14 +432,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var requestedSelection: ComposerSelectionPayload? private var theme = ComposerThemePayload( text: "#262626", - placeholder: "#8e8e93", - chipBackground: "#f2f2f7", - chipBorder: "#dedee3", - chipText: "#262626", - skillBackground: "#f9e8fb", - skillBorder: "#e5a6eb", - skillText: "#a21caf", - fileTint: "#737373" + placeholder: "#8e8e93" ) private var fontFamily = "DMSans-Regular" private var fontSize: CGFloat = 14 @@ -906,40 +885,26 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { - let isSkill = token.type == "skill" - let accent = token.accent.flatMap { UIColor(composerHex: $0) } - let foreground = UIColor(composerHex: theme.chipText) ?? .label - let border = UIColor(composerHex: theme.chipBorder) ?? .separator - let tint = accent.map { blend($0, over: foreground, weight: 0.22) } - ?? UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel - let iconName = token.symbol ?? (isSkill ? "cube" : "doc") - let iconImage = token.iconUri.flatMap(iconImage(for:)) - let style = ComposerChipStyle( - tint: tint, - backgroundColor: accent?.withAlphaComponent(0.11) ?? UIColor( - composerHex: isSkill ? theme.skillBackground : theme.chipBackground - ) ?? .secondarySystemFill, - borderColor: accent.map { blend($0, over: border, weight: 0.34) } ?? UIColor( - composerHex: isSkill ? theme.skillBorder : theme.chipBorder - ) ?? .separator, - textColor: tint - ) + let color = token.accent.flatMap { UIColor(composerHex: $0) } + ?? UIColor(composerHex: theme.text) ?? .label + let chipFont = UIFont(name: "DMSans-Medium", size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + let icon = token.iconUri.flatMap(iconImage(for:)).map { + token.tintsIcon == true ? Self.tinted($0, color: color) : $0 + } let image = renderChip( label: token.label, detail: token.detail, - iconName: iconName, - iconImage: iconImage, - style: style + icon: icon, + color: color, + font: chipFont ) - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) - let baselineOffset = floor((font.capHeight - image.size.height) / 2) let attachment = ComposerTextAttachment( source: token.source, label: token.label, image: image, size: image.size, - baselineOffset: baselineOffset + baselineOffset: chipFont.ascender - image.size.height ) let attributedAttachment = NSMutableAttributedString(attachment: attachment) attributedAttachment.addAttributes( @@ -949,78 +914,35 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return attributedAttachment } - /// Chip glyphs with no SF Symbol that reads correctly. A pull request would otherwise land on - /// `arrow.triangle.branch`, a road-sign fork that says "branch", not "pull request", so it is - /// drawn from the same lucide geometry web and Android use. - private static func vectorIcon(named name: String, size: CGFloat, color: UIColor) -> UIImage? { - guard name == "git-pull-request" else { return nil } - return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { _ in - let s = size / 24 // lucide authors on a 24pt grid. - let path = UIBezierPath() - for centre in [CGPoint(x: 18 * s, y: 18 * s), CGPoint(x: 6 * s, y: 6 * s)] { - path.append(UIBezierPath(arcCenter: centre, radius: 3 * s, startAngle: 0, - endAngle: .pi * 2, clockwise: true)) - } - path.move(to: CGPoint(x: 13 * s, y: 6 * s)) - path.addLine(to: CGPoint(x: 16 * s, y: 6 * s)) - path.addCurve(to: CGPoint(x: 18 * s, y: 8 * s), - controlPoint1: CGPoint(x: 17.1 * s, y: 6 * s), - controlPoint2: CGPoint(x: 18 * s, y: 6.9 * s)) - path.addLine(to: CGPoint(x: 18 * s, y: 15 * s)) - path.move(to: CGPoint(x: 6 * s, y: 9 * s)) - path.addLine(to: CGPoint(x: 6 * s, y: 21 * s)) - path.lineWidth = 2 * s - path.lineCapStyle = .round - path.lineJoinStyle = .round - color.setStroke() - path.stroke() - } - } - private func renderChip( label: String, detail: String?, - iconName: String, - iconImage: UIImage?, - style: ComposerChipStyle + icon: UIImage?, + color: UIColor, + font: UIFont ) -> UIImage { - // Kept in step with `T3ContextChipVectorIcon` in the markdown module: a chip drawn here and - // the same chip drawn in a sent message have to be the same picture. - let chipFontSize = fontSize * 0.86 - let font = UIFont(name: "DMSans-Medium", size: chipFontSize) - ?? UIFont.systemFont(ofSize: chipFontSize, weight: .medium) - let fallbackIcon = Self.vectorIcon(named: iconName, size: 14, color: style.textColor) - ?? UIImage( - systemName: iconName, - withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) - ) - let icon = iconImage ?? fallbackIcon - // The size reads as metadata, not part of the name, so it renders a step down from the - // label the way the web chip does. let paragraph = NSMutableParagraphStyle() paragraph.alignment = .left - let detailFont = UIFont(name: "DMSans-Medium", size: chipFontSize * 0.84) - ?? UIFont.systemFont(ofSize: chipFontSize * 0.84, weight: .medium) + paragraph.lineBreakMode = .byTruncatingMiddle let attributedLabel = NSMutableAttributedString( string: label, - attributes: [.font: font, .foregroundColor: style.textColor, .paragraphStyle: paragraph] + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: paragraph] ) + // The size reads as metadata, not part of the name, so it renders a step down from the + // label the way the web chip does. if let detail, !detail.isEmpty { + let detailFont = font.withSize(font.pointSize * 0.84) attributedLabel.append( NSAttributedString( string: " \(detail)", - attributes: [ - .font: detailFont, - .foregroundColor: style.textColor, - .paragraphStyle: paragraph, - ] + attributes: [.font: detailFont, .foregroundColor: color, .paragraphStyle: paragraph] ) ) } - let iconWidth: CGFloat = icon == nil ? 0 : chipFontSize * 1.17 - let iconGap: CGFloat = icon == nil ? 0 : chipFontSize * 0.33 - let padding = chipFontSize * 0.5 - let height = ceil(chipFontSize * 1.41) + let iconSide = font.pointSize + let iconSlot: CGFloat = icon == nil ? 0 : iconSide * 1.25 + let padding: CGFloat = 5 + let height = ceil(font.ascender - font.descender) // A long path would otherwise draw a chip wider than the composer and clip. Cap the label // to the text the editor can actually show and truncate inside it, as Android's // `maximumWidth` does, so the chip always fits the line it sits on. @@ -1030,58 +952,35 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro if availableWidth <= 0 { chipsNeedMeasuredWidth = true } - let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap) - paragraph.lineBreakMode = .byTruncatingMiddle - attributedLabel.addAttribute( - .paragraphStyle, - value: paragraph, - range: NSRange(location: 0, length: attributedLabel.length) - ) - let measured = attributedLabel.size() - let textSize = CGSize(width: min(measured.width, maximumLabelWidth), height: measured.height) - let width = ceil(padding * 2 + iconWidth + iconGap + textSize.width) + let maximumLabelWidth = max(font.pointSize * 3, availableWidth - padding * 2 - iconSlot) + let labelWidth = min(attributedLabel.size().width, maximumLabelWidth) + let width = ceil(padding * 2 + iconSlot + labelWidth) let format = UIGraphicsImageRendererFormat.preferred() format.opaque = false let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) - return renderer.image { context in - let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) - let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: chipFontSize * 0.5) - style.backgroundColor.setFill() - path.fill() - style.borderColor.setStroke() - path.lineWidth = 1 - path.stroke() - - var x = padding + return renderer.image { _ in if let icon { - let renderedIcon = iconImage == nil - ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) - : icon - renderedIcon.draw( - in: CGRect(x: x, y: (height - iconWidth) / 2, width: iconWidth, height: iconWidth) + icon.draw( + in: CGRect(x: padding, y: (height - iconSide) / 2, width: iconSide, height: iconSide) ) - x += iconWidth + iconGap } // Exactly the measured width: a spare pixel here would let a capped label draw past // the cap instead of truncating inside it. attributedLabel.draw( - in: CGRect(x: x, y: (height - textSize.height) / 2, width: textSize.width, height: textSize.height) + in: CGRect(x: padding + iconSlot, y: 0, width: labelWidth, height: height) ) - context.cgContext.setAllowsAntialiasing(true) } } - private func blend(_ accent: UIColor, over base: UIColor, weight: CGFloat) -> UIColor { - var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0 - var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 - accent.getRed(&ar, green: &ag, blue: &ab, alpha: &aa) - base.getRed(&br, green: &bg, blue: &bb, alpha: &ba) - return UIColor( - red: ar * weight + br * (1 - weight), - green: ag * weight + bg * (1 - weight), - blue: ab * weight + bb * (1 - weight), - alpha: aa * weight + ba * (1 - weight) - ) + private static func tinted(_ image: UIImage, color: UIColor) -> UIImage { + let format = UIGraphicsImageRendererFormat.preferred() + format.scale = image.scale + format.opaque = false + return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in + color.setFill() + UIRectFill(CGRect(origin: .zero, size: image.size)) + image.draw(at: .zero, blendMode: .destinationIn, alpha: 1) + } } private func iconImage(for uri: String) -> UIImage? { @@ -1230,7 +1129,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - let nextSize = textView.contentSize + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters, so layout stops at the second line. The width + // comes from the line fragment because usedRect spans the container once the view + // scrolls. A trailing newline's empty line only exists as the extra fragment. + let layout = textView.layoutManager + let container = textView.textContainer + var lines = 0 + var width: CGFloat = 0 + var height: CGFloat = 0 + let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs) + layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in + lines += 1 + width = used.width + height = used.maxY + if lines > 1 { stop.pointee = true } + } + if lines < 2, layout.extraLineFragmentTextContainer === container { + lines += 1 + height = max(height, layout.extraLineFragmentUsedRect.maxY) + } + let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fdd83742516f..441ee7beb6d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,6 +40,7 @@ "config:preview": "APP_VARIANT=preview expo config", "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", + "sync:chip-icons": "node scripts/sync-context-chip-icons.mjs", "sync:pierre-icons": "node scripts/sync-pierre-file-icons.mjs", "measure-renders": "node scripts/measure-renders.mts", "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", @@ -133,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs b/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs index 388afbf3f67d..c221c14ca447 100644 --- a/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs +++ b/apps/mobile/plugins/withAndroidFbjniVersion.test.mjs @@ -86,6 +86,7 @@ describe("withAndroidFbjniVersion", () => { expect(twice).toBe(once); }); + // regression: #20118 fresh Android builds no longer crash on launch it("writes Kotlin syntax into a Kotlin build script", async () => { const result = await prebuildRootGradle("build.gradle.kts", "plugins {}\n"); diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index a225b7087c86..25ed7dd41d27 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -2,61 +2,9 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; -import { - customThemeNames, - getGeneratedUniwindThemeOutputs, - renderDefaultThemeVariablesJSON, - renderUniwindThemesCSS, -} from "./generate-uniwind-themes.mts"; -import { readDefaultMobileThemeVariables } from "../src/lib/mobileTheme.test-support"; +import { customThemeNames, renderUniwindThemesCSS } from "./generate-uniwind-themes.mts"; describe("generate mobile Uniwind themes", () => { - it("keeps the committed outputs current", () => { - const staleOutputs = getGeneratedUniwindThemeOutputs() - .filter( - ([filename, contents]) => - !NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents, - ) - .map(([filename]) => NodePath.relative(import.meta.dirname, filename)); - - expect( - staleOutputs, - "Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.", - ).toEqual([]); - }); - - it("registers every custom palette for both appearances", () => { - expect(customThemeNames).toEqual([ - "t3-chat-light", - "t3-chat-dark", - "grove-light", - "grove-dark", - "ocean-light", - "ocean-dark", - "ember-light", - "ember-dark", - "iris-light", - "iris-dark", - ]); - - const stylesheet = renderUniwindThemesCSS(); - for (const themeName of customThemeNames) { - expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1); - } - }); - - it("keeps the default runtime bridge and generated CSS on the same palette", () => { - const variables = JSON.parse(renderDefaultThemeVariablesJSON()); - - expect(variables.light).toEqual(readDefaultMobileThemeVariables("light")); - expect(variables.dark).toEqual(readDefaultMobileThemeVariables("dark")); - expect(variables.light["--color-screen"]).toBe("#fcfcfc"); - expect(variables.light["--color-drawer"]).toBe("#fafafa"); - expect(variables.dark["--color-screen"]).toBe("#0a0a0a"); - expect(variables.dark["--color-drawer"]).toBe("#000000"); - expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); - }); - it("gives every theme the same variables and a fixed Clerk palette for its appearance", () => { const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8") + diff --git a/apps/mobile/scripts/network-module.test.ts b/apps/mobile/scripts/network-module.test.ts deleted file mode 100644 index a304226c7f07..000000000000 --- a/apps/mobile/scripts/network-module.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; - -// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. -describe.skipIf(NodeOS.platform() !== "darwin")("NetworkModule native path handoff", () => { - let directory: string; - let executable: string; - - beforeAll(() => { - directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-network-test-")); - executable = NodePath.join(directory, "network-regression"); - const source = NodeFS.readFileSync( - new URL("../node_modules/expo-network/ios/NetworkModule.swift", import.meta.url), - "utf8", - ); - // Compile the dependency's query and conversion methods, replacing only - // NWPathMonitor so we can deliver updates during and after cancellation. - const start = source.indexOf(" private func getNetworkPathAsync()"); - if (start === -1) throw new Error("NetworkModule query implementation not found"); - const methods = source - .slice(start, source.lastIndexOf("\n}")) - .replace("private func getNetworkStateAsync", "func getNetworkStateAsync"); - const module = NodePath.join(directory, "NetworkModule.swift"); - NodeFS.writeFileSync( - module, - `import Foundation\nfinal class NetworkModule {\n private let monitorQueue = DispatchQueue.global(qos: .default)\n${methods}\n}\n`, - ); - const types = NodePath.join(directory, "NetworkType.swift"); - NodeFS.writeFileSync( - types, - NodeFS.readFileSync( - new URL("../node_modules/expo-network/ios/NetworkType.swift", import.meta.url), - "utf8", - ).replace(/^import Network\n/m, ""), - ); - NodeChildProcess.execFileSync( - "swiftc", - [ - "-swift-version", - "5", - "-sanitize=thread", - module, - types, - NodeURL.fileURLToPath(new URL("./fixtures/NetworkModuleRegression.swift", import.meta.url)), - "-o", - executable, - ], - { timeout: 30_000, encoding: "utf8" }, - ); - }); - - afterAll(() => { - if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); - }); - - it.each([ - ["first", "keeps the first path when several updates arrive before the query resumes"], - ["offline", "reports a disconnected path"], - ["concurrent", "reads paths during overlapping queries and late callbacks without data races"], - ])("%s: %s", (name) => { - expect( - NodeChildProcess.execFileSync(executable, [name], { - encoding: "utf8", - timeout: 15_000, - env: { ...process.env, TSAN_OPTIONS: "halt_on_error=1" }, - }).trim(), - ).toBe("passed"); - }); -}); diff --git a/apps/mobile/scripts/notification-center-manager.test.ts b/apps/mobile/scripts/notification-center-manager.test.ts deleted file mode 100644 index e7a059496af6..000000000000 --- a/apps/mobile/scripts/notification-center-manager.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; - -// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. -describe.skipIf(NodeOS.platform() !== "darwin")( - "NotificationCenterManager native concurrency", - () => { - let directory: string; - let executable: string; - - beforeAll(() => { - directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-")); - executable = NodePath.join(directory, "notification-regression"); - const source = NodeFS.readFileSync( - new URL( - "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift", - import.meta.url, - ), - "utf8", - ); - const manager = NodePath.join(directory, "NotificationCenterManager.swift"); - NodeFS.writeFileSync( - manager, - source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), - ); - NodeChildProcess.execFileSync( - "swiftc", - [ - "-swift-version", - "5", - "-sanitize=thread", - manager, - NodeURL.fileURLToPath( - new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), - ), - "-o", - executable, - ], - { timeout: 30_000, encoding: "utf8" }, - ); - }); - - afterAll(() => { - if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); - }); - - it.each([ - ["reentrant", "allows callbacks to replace delegates without deadlocking"], - ["handoff", "delivers responses to delegates registering during delivery"], - ["pending", "retains new responses received while replaying pending responses"], - ["concurrent", "registers, removes, and broadcasts concurrently without data races"], - ])("%s: %s", (name) => { - const output = NodeChildProcess.execFileSync(executable, [name], { - encoding: "utf8", - timeout: 15_000, - }); - expect(output.trim()).toBe("passed"); - }); - }, -); diff --git a/apps/mobile/scripts/permissions-service.test.ts b/apps/mobile/scripts/permissions-service.test.ts deleted file mode 100644 index f1851723550a..000000000000 --- a/apps/mobile/scripts/permissions-service.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Compiles the native dependency regression directly. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeModule from "node:module"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { expect, it } from "vite-plus/test"; - -// oxlint-disable-next-line t3code/no-global-process-runtime -- This test compiles against the host Foundation framework. -it.skipIf(NodeOS.platform() !== "darwin")( - "registers and reads native permissions concurrently without corrupting the registry", - () => { - const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-permissions-test-")); - try { - const require = NodeModule.createRequire(import.meta.url); - const core = NodePath.dirname( - NodeModule.createRequire(require.resolve("expo/package.json")).resolve( - "expo-modules-core/package.json", - ), - ); - const read = (relativePath: string) => - NodeFS.readFileSync(NodePath.join(core, relativePath), "utf8").replace( - /^#import .*$/gm, - "", - ); - const source = NodePath.join(directory, "regression.m"); - NodeFS.writeFileSync( - source, - [ - "#import ", - "#import ", - "#include ", - "#include ", - "typedef void (^EXPromiseResolveBlock)(id result);", - "typedef void (^EXPromiseRejectBlock)(NSString *, NSString *, NSError *);", - "#define RCTLogWarn(...)", - read("ios/Interfaces/Permissions/EXPermissionsInterface.h"), - read("ios/Legacy/Services/Permissions/EXPermissionsService.h"), - read("ios/Legacy/Services/Permissions/EXPermissionsService.m"), - NodeFS.readFileSync( - new URL("./fixtures/PermissionsServiceRegression.m", import.meta.url), - "utf8", - ), - ].join("\n"), - ); - const executable = NodePath.join(directory, "regression"); - NodeChildProcess.execFileSync( - "clang", - [ - "-fobjc-arc", - "-fblocks", - "-fsanitize=thread", - "-framework", - "Foundation", - source, - "-o", - executable, - ], - { encoding: "utf8", timeout: 30_000 }, - ); - expect( - NodeChildProcess.execFileSync(executable, { encoding: "utf8", timeout: 15_000 }).trim(), - ).toBe("passed"); - } finally { - NodeFS.rmSync(directory, { recursive: true, force: true }); - } - }, -); diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs new file mode 100644 index 000000000000..73c6ec7e5e00 --- /dev/null +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -0,0 +1,103 @@ +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const appDirectory = NodePath.resolve(scriptDirectory, ".."); +const repositoryRoot = NodePath.resolve(appDirectory, "../.."); +const outputDirectory = NodePath.join(appDirectory, "assets/context-chip-icons"); +const generatedModulePath = NodePath.join(appDirectory, "src/lib/contextChipIcons.generated.ts"); + +const requireFromWeb = NodeModule.createRequire( + NodePath.join(repositoryRoot, "apps/web/package.json"), +); +const requireFromScripts = NodeModule.createRequire( + NodePath.join(repositoryRoot, "scripts/package.json"), +); +const sharp = requireFromScripts("sharp"); +const lucideIconsDirectory = NodePath.join( + NodePath.dirname(requireFromWeb.resolve("lucide-react/package.json")), + "dist/esm/icons", +); +const webChipSource = NodeFS.readFileSync( + NodePath.join(repositoryRoot, "apps/web/src/components/composerInlineChip.ts"), + "utf8", +); +const skillSvg = webChipSource.match(/SKILL_CHIP_ICON_SVG = `([\s\S]*?)`;/)?.[1]; + +if (!skillSvg) { + throw new Error( + "Could not read SKILL_CHIP_ICON_SVG from apps/web/src/components/composerInlineChip.ts", + ); +} + +const lucideIcons = [ + "circle-dashed", + "circle-dot", + "folder-git-2", + "git-branch", + "git-commit-horizontal", + "hash", + "laptop", + "link", + "tag", + "ticket", + "film", + "git-merge", + "git-pull-request-arrow", + "git-pull-request-closed", + "git-pull-request-draft", + "image", + "message-circle", + "messages-square", + "mouse-pointer-click", + "terminal", + "cpu", +]; + +async function lucideSvg(name) { + const module = await import( + NodeURL.pathToFileURL(NodePath.join(lucideIconsDirectory, `${name}.js`)).href + ); + const body = module.__iconNode + .map(([tag, { key: _key, ...attributes }]) => { + const attributeText = Object.entries(attributes) + .map(([attribute, value]) => `${attribute}="${value}"`) + .join(" "); + return `<${tag} ${attributeText}/>`; + }) + .join(""); + return `${body}`; +} + +async function renderIcon(token, svg) { + const sized = svg + .replace(/^]*?>/, (tag) => + tag + .replace(/ (?:width|height)="[^"]*"/g, "") + .replace(" + ` ${/^\w+$/.test(token) ? token : `"${token}"`}: require("../../assets/context-chip-icons/${token}.png"),`, + ) + .join("\n")}\n} as const satisfies Readonly>;\n`; +NodeFS.writeFileSync(generatedModulePath, generatedSource); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b9f1f16b2c28..d7cafd5ba21b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -39,8 +39,8 @@ import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorksp import { PullRequestRouteScreen, PullRequestFileRouteScreen, - PullRequestFilesRouteScreen, } from "./features/pull-requests/PullRequestRouteScreen"; +import { PullRequestFilesSheet } from "./features/pull-requests/PullRequestFiles"; import { PullRequestReviewSheet } from "./features/pull-requests/PullRequestReviewSheet"; import { PullRequestReviewersScreen } from "./features/pull-requests/PullRequestReviewersScreen"; import { @@ -71,6 +71,7 @@ import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; +import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; @@ -564,6 +565,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "SettingsLegal", "SettingsSheet", "ThreadAgents", + "ThreadBackgroundWork", "ThreadPullRequests", "ThreadQueue", "ThreadReviewComment", @@ -735,12 +737,13 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: DETAIL_HEADER_OPTIONS, }), PullRequestFiles: createNativeStackScreen({ - screen: PullRequestFilesRouteScreen, + screen: PullRequestFilesSheet, options: { + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.6, 0.92], sheetGrabberVisible: true, - headerShown: false, + sheetCornerRadius: Platform.OS === "android" ? 28 : undefined, }, }), PullRequestReviewers: createNativeStackScreen({ @@ -762,19 +765,18 @@ const RootStackConfig = createWorkspaceStackNavigator({ options: { ...(Platform.OS === "android" ? { presentation: "fullScreenModal" as const } - : FORM_SHEET_PRESENTATION_OPTIONS), + : { ...SHEET_GLASS_HEADER_OPTIONS, ...FORM_SHEET_PRESENTATION_OPTIONS }), sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.75, 0.92], sheetGrabberVisible: Platform.OS !== "android", - headerShown: false, }, }), PullRequestReview: createNativeStackScreen({ screen: PullRequestReviewSheet, options: { + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.75, 0.92], sheetGrabberVisible: true, - headerShown: false, }, }), Thread: createNativeStackScreen({ @@ -875,6 +877,15 @@ const RootStackConfig = createWorkspaceStackNavigator({ sheetGrabberVisible: true, }, }), + ThreadBackgroundWork: createNativeStackScreen({ + screen: ThreadBackgroundWorkSheet, + options: { + ...FORM_SHEET_PRESENTATION_OPTIONS, + headerShown: false, + sheetAllowedDetents: [0.5, 0.9], + sheetGrabberVisible: true, + }, + }), ThreadAgents: createNativeStackScreen({ screen: ThreadAgentsSheet, options: { @@ -906,6 +917,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, options: { + ...(Platform.OS === "ios" ? SHEET_GLASS_HEADER_OPTIONS : undefined), ...FORM_SHEET_PRESENTATION_OPTIONS, sheetAllowedDetents: [0.8, 0.92], sheetGrabberVisible: true, diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx new file mode 100644 index 000000000000..d528186406c2 --- /dev/null +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -0,0 +1,85 @@ +import type { ModelSelection, ProviderDriverKind } from "@t3tools/contracts"; +import { + buildTraitsTriggerDisplay, + getPromptControlledTraits, +} from "@t3tools/client-runtime/model-traits"; +import type { ModelOption } from "../lib/modelOptions"; +import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; +import { AppText as Text } from "./ui/AppText"; +import { ComposerInlineControl } from "./ComposerToolbar"; +import { ProviderIcon, ProviderInstanceIcon } from "./ProviderIcon"; + +export function ComposerModelControl(props: { + readonly modelOption: ModelOption | null; + readonly selection: ModelSelection | null; + readonly provider: ProviderDriverKind | undefined; + readonly reportedSelection?: ModelSelection | null | undefined; + readonly prompt: string; + readonly surfaceColor: string; + readonly disabled?: boolean; + readonly iconOnly?: boolean; + readonly showSummary?: boolean; + readonly onPress: () => void; +}) { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: props.modelOption?.capabilities, + selections: props.selection?.options, + }); + const promptTraits = getPromptControlledTraits(descriptors, props.prompt); + const summary = props.provider + ? buildTraitsTriggerDisplay({ + provider: props.provider, + descriptors, + primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, + modelSelection: props.selection, + reportedModelSelection: props.reportedSelection, + }).label + : ""; + const badge = props.modelOption?.providerBadge; + const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; + return ( + + badge?.showBadge ? ( + + ) : ( + + ) + } + label={label} + iconOnly={props.iconOnly} + suffix={ + props.showSummary !== false && summary ? ( + + {summary} + + ) : undefined + } + maxWidth="100%" + onPress={props.onPress} + /> + ); +} diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d6bbf9d99294..f8e3c1447f04 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -13,6 +13,8 @@ import { type ViewStyle, } from "react-native"; +import Animated, { FadeIn, LinearTransition } from "react-native-reanimated"; + import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; @@ -21,6 +23,10 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; +const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); +// Android runs no composer layout transitions, so the toolbar never trails the dock. +const INLINE_CONTROL_WIDTH_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(180); export function ComposerInlineControl(props: { readonly accessibilityHint?: string; @@ -30,6 +36,9 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + readonly suffix?: ReactNode; + /** Show only the icon and chevron; the label still names the control for accessibility. */ + readonly iconOnly?: boolean; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; @@ -38,56 +47,80 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); + // The label fades in as the control widens, and leaves at once: an exiting view keeps + // drawing at its old place while the chevron moves into it. The clip keeps content laid out + // at the final width inside the frame while it eases there. + const animatesLabel = props.iconOnly !== undefined; return ( - - {props.renderIcon ? ( - - {props.renderIcon(smallIconSize)} - - ) : props.icon ? ( - - ) : null} - - {props.label} - - {props.showChevron === false ? null : ( - - )} - + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + + ) : props.icon ? ( + + ) : null} + {props.iconOnly ? null : ( + + + {props.label} + + + )} + {!props.iconOnly && props.suffix ? ( + {props.suffix} + ) : null} + {props.showChevron === false ? null : ( + + )} + + ); } diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index b35c7cc58f8f..1b259f1ecbfa 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,5 +1,6 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; -import { Column, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { weight } from "@expo/ui/jetpack-compose/modifiers"; import { useState, type ReactNode } from "react"; import { View } from "react-native"; @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: { readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; + /** One row with the subtitle after the title, for screens short on height. */ + readonly compact?: boolean; }) { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); @@ -65,6 +68,7 @@ export function AndroidScreenHeader(props: { }} > @@ -95,6 +99,26 @@ export function AndroidScreenHeader(props: { > {props.title} + ) : props.compact ? ( + + + {props.title} + + + {props.subtitle} + + ) : ( : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} actions={ search ? [ diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 621006d949c0..e3db494219d7 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -68,6 +68,8 @@ export interface AppBarProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: boolean; readonly trailing?: ReactNode; readonly matchSearchSurface?: boolean; readonly options?: AppNativeStackNavigationOptions; diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index 2c028fded803..22f8ca65cc42 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -10,9 +10,11 @@ export function NativeAppBar(props: { readonly title: ReactElement; readonly navigationIcon?: ReactElement; readonly actions?: ReactElement; + /** Shorter bar for a one-row title. */ + readonly singleLine?: boolean; }) { const { colors } = useControlTheme(); - const layout = useToolbarLayout(); + const layout = useToolbarLayout(false, props.singleLine); return ( // The bar's height is fixed, so React Native lays out the screen below it from the first frame // instead of at zero height until Compose has measured and reported the bar. diff --git a/apps/mobile/src/components/ui/headerMenu.test.ts b/apps/mobile/src/components/ui/headerMenu.test.ts deleted file mode 100644 index c2d772c11fa1..000000000000 --- a/apps/mobile/src/components/ui/headerMenu.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - androidHeaderMenuActions, - findHeaderMenuAction, - splitAndroidHeaderActions, -} from "./headerMenu.android"; -import type { AppBarMenuItem } from "./AppBar.types"; - -const items: ReadonlyArray = [ - { - id: "sessions", - title: "Sessions", - items: [ - { - id: "terminal-session:main", - title: "Main shell", - subtitle: "Running · workspace", - selected: true, - onPress: () => {}, - }, - { id: "closed", title: "Closed shell", disabled: true, onPress: () => {} }, - ], - }, -]; - -describe("header menus", () => { - it("keeps named sections as Android submenus", () => { - const actions = androidHeaderMenuActions([ - { - id: "text-size", - title: "Text size", - inline: true, - items: [{ id: "font-increase", title: "A+", onPress: () => {} }], - }, - ]); - expect(actions[0]?.title).toBe("Text size"); - expect(actions[0]?.subactions?.[0]?.id).toBe("font-increase"); - }); - - it("flattens inline groups while keeping their action IDs", () => { - const actions = androidHeaderMenuActions([ - { id: "modes", inline: true, items: [{ id: "code", title: "Code", onPress: () => {} }] }, - ]); - expect(actions.map(({ id }) => id)).toEqual(["code"]); - }); - - it("keeps stable action IDs, selection, and disabled state inside submenus", () => { - const menu = androidHeaderMenuActions(items); - expect(menu[0]?.id).toBe("sessions"); - expect(menu[0]?.subactions?.[0]).toMatchObject({ - id: "terminal-session:main", - title: "Main shell", - subtitle: "Running · workspace", - state: "on", - }); - expect(menu[0]?.subactions?.[1]).toMatchObject({ - id: "closed", - attributes: { disabled: true }, - }); - }); - - it("resolves nested action IDs without dispatching group or missing IDs", () => { - expect(findHeaderMenuAction(items, "terminal-session:main")).toMatchObject({ - title: "Main shell", - selected: true, - }); - expect(findHeaderMenuAction(items, "sessions")).toBeUndefined(); - expect(findHeaderMenuAction(items, "removed-session")).toBeUndefined(); - }); - - it("keeps overflow-only Android header actions in the overflow menu", () => { - const action = (accessibilityLabel: string, overflowOnly?: boolean) => ({ - accessibilityLabel, - icon: "trash" as const, - onPress: () => {}, - overflowOnly, - }); - const labels = ( - split: ReturnType>>, - ) => ({ - visible: split.visibleActions.map((item) => item.accessibilityLabel), - overflow: split.overflowActions.map((item) => item.accessibilityLabel), - }); - const open = action("Open"); - const remove = action("Delete", true); - expect(labels(splitAndroidHeaderActions([open, action("Comment")], false))).toEqual({ - visible: ["Open", "Comment"], - overflow: [], - }); - expect(labels(splitAndroidHeaderActions([open, remove], true))).toEqual({ - visible: ["Open"], - overflow: ["Delete"], - }); - expect(labels(splitAndroidHeaderActions([remove], true))).toEqual({ - visible: [], - overflow: ["Delete"], - }); - expect( - labels(splitAndroidHeaderActions([action("Review"), action("Comment"), open, remove], true)), - ).toEqual({ visible: ["Review", "Comment", "Open"], overflow: ["Delete"] }); - }); -}); diff --git a/apps/mobile/src/components/ui/useToolbarLayout.ts b/apps/mobile/src/components/ui/useToolbarLayout.ts index faa02c198289..736ac27ed4eb 100644 --- a/apps/mobile/src/components/ui/useToolbarLayout.ts +++ b/apps/mobile/src/components/ui/useToolbarLayout.ts @@ -4,8 +4,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; -/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ -export function useToolbarLayout(embedded = false) { +/** + * Shared Android header geometry; class-based spacing uses the app's 14dp rem. + * `singleLine` sizes the toolbar for a title row with the subtitle inline. + */ +export function useToolbarLayout(embedded = false, singleLine = false) { const insets = useSafeAreaInsets(); const title = useScaledTextRole("title"); const subtitle = useScaledTextRole("label"); @@ -13,7 +16,9 @@ export function useToolbarLayout(embedded = false) { const { fontScale } = useWindowDimensions(); return { height: Math.ceil( - Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + singleLine + ? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1) + : Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), ), paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), paddingBottom: 7 * scale, diff --git a/apps/mobile/src/connection/app-state-wakeups.test.ts b/apps/mobile/src/connection/app-state-wakeups.test.ts deleted file mode 100644 index 4e8bdf1edf4a..000000000000 --- a/apps/mobile/src/connection/app-state-wakeups.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - MOBILE_BACKGROUND_RECONNECT_AFTER_MS, - mobileApplicationActiveWakeup, -} from "./app-state-wakeups"; - -describe("mobileApplicationActiveWakeup", () => { - it("uses a fast probe after a short interruption", () => { - expect(mobileApplicationActiveWakeup(null, 20_000)).toBe("application-active-probe"); - expect( - mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS - 1), - ).toBe("application-active-probe"); - }); - - it("replaces the session after a meaningful background suspension", () => { - expect( - mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS), - ).toBe("application-active-reconnect"); - }); -}); diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 2107d9305e93..4c88a03ccf2a 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -11,7 +11,6 @@ import { type OrchestrationV2ShellSnapshot, type OrchestrationV2ThreadDetailSnapshot, type ThreadPullRequestLink, - type VcsListRefsResult, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -144,20 +143,6 @@ const THREAD_SNAPSHOT: OrchestrationV2ThreadDetailSnapshot = { updatedAt: NOW, }, }; -const REFS: VcsListRefsResult = { - refs: [ - { - name: "main", - current: true, - isDefault: true, - worktreePath: "/repo", - }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, -}; function cacheId( environmentId: EnvironmentId, @@ -321,19 +306,6 @@ describe("mobile SQLite environment cache store", () => { }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); - it.effect("round-trips schema-validated VCS refs", () => - Effect.gen(function* () { - const memory = makeDatabase(); - const store = yield* make().pipe( - Effect.provideService(MobileDatabase.MobileDatabase, memory.database), - ); - - yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); - - expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS)); - }), - ); - it.effect("deletes a corrupt cache record and treats it as a miss", () => Effect.gen(function* () { const memory = makeDatabase(); @@ -347,55 +319,4 @@ describe("mobile SQLite environment cache store", () => { expect(memory.removed).toEqual([id]); }), ); - - it.effect("removes one persisted VCS ref snapshot", () => - Effect.gen(function* () { - const memory = makeDatabase(); - const store = yield* make().pipe( - Effect.provideService(MobileDatabase.MobileDatabase, memory.database), - ); - yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); - - yield* store.removeVcsRefs(ENVIRONMENT_ID, "/repo"); - - expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); - expect(memory.removed).toContain(cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo")); - }), - ); - - it.effect("clears every persisted VCS ref snapshot in one environment", () => - Effect.gen(function* () { - const memory = makeDatabase(); - const store = yield* make().pipe( - Effect.provideService(MobileDatabase.MobileDatabase, memory.database), - ); - const otherEnvironmentId = EnvironmentId.make("environment-2"); - yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); - yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo-worktree", REFS); - yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); - - yield* store.clearVcsRefs(ENVIRONMENT_ID); - - expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); - expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo-worktree")).toEqual(Option.none()); - expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); - }), - ); - - it.effect("clears one environment without touching another", () => - Effect.gen(function* () { - const memory = makeDatabase(); - const store = yield* make().pipe( - Effect.provideService(MobileDatabase.MobileDatabase, memory.database), - ); - const otherEnvironmentId = EnvironmentId.make("environment-2"); - yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); - yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); - - yield* store.clear(ENVIRONMENT_ID); - - expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); - expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); - }), - ); }); diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts index 6b0329aa2727..755c4f639d78 100644 --- a/apps/mobile/src/dependency-graph.test.ts +++ b/apps/mobile/src/dependency-graph.test.ts @@ -205,48 +205,10 @@ function findCycles(graph: PlatformGraph): ReadonlyArray> const graphs = PLATFORMS.map(buildPlatformGraph); -function upwardEdges(): string[] { - const union = new Set(); - for (const graph of graphs) { - for (const edge of graph.crossLayerEdges) { - union.add(edge); - } - } - return [...union].sort(); -} - describe("mobile dependency graph", () => { it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { const graph = graphs.find((candidate) => candidate.platform === platform)!; expect(graph.files.length).toBeGreaterThan(500); expect(findCycles(graph)).toEqual([]); }); - - it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { - const edges = upwardEdges(); - const edgesFor = (from: Layer, to: Layer): string[] => - edges.filter((edge) => { - const [source, target] = edge.split(" -> "); - return layerOf(source!) === from && layerOf(target!) === to; - }); - - const ceilings: ReadonlyArray = [ - ["state", "features", 6, "state must not add imports from features"], - ["lib", "features", 7, "lib must not add imports from features"], - // components -> features: mostly the appearance preferences provider - // and the layout toolbar bridges. - ["components", "features", 30, "components must not add imports from features"], - // native -> features: native glue reading appearance/keyboard/review features. - ["native", "features", 8, "native must not add imports from features"], - ["lib", "state", 10, "lib must not add imports from state"], - ]; - - for (const [from, to, ceiling, message] of ceilings) { - const layerEdges = edgesFor(from, to); - expect( - layerEdges.length, - `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, - ).toBeLessThanOrEqual(ceiling); - } - }); }); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts deleted file mode 100644 index 59bb850a2016..000000000000 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - os: "android", - version: 36, - openSettings: vi.fn(), - native: null as { - configure?: ReturnType; - clear?: ReturnType; - openLiveUpdateSettings?: ReturnType; - } | null, - config: { scheme: ["t3code-preview"], extra: { iosPersonalTeamBuild: false } }, - requireModule: vi.fn(), -})); - -vi.mock("expo", () => ({ requireOptionalNativeModule: mocks.requireModule })); -vi.mock("expo-constants", () => ({ default: { expoConfig: mocks.config } })); -vi.mock("react-native", () => ({ - Linking: { openSettings: mocks.openSettings }, - Platform: { - get Version() { - return mocks.version; - }, - get OS() { - return mocks.os; - }, - }, -})); - -beforeEach(() => { - vi.resetModules(); - mocks.os = "android"; - mocks.version = 36; - mocks.openSettings.mockReset().mockResolvedValue(undefined); - mocks.native = { configure: vi.fn(), clear: vi.fn() }; - mocks.config.extra.iosPersonalTeamBuild = false; - mocks.requireModule.mockReset().mockImplementation(() => mocks.native); -}); - -describe("Android native notification capability", () => { - it("opens the Live Update controls on supported Android builds", async () => { - mocks.native!.openLiveUpdateSettings = vi.fn(() => true); - const { openAndroidLiveUpdateSettings, supportsAndroidLiveUpdateSettings } = - await import("./androidNotifications"); - expect(supportsAndroidLiveUpdateSettings()).toBe(true); - await openAndroidLiveUpdateSettings(); - expect(mocks.native!.openLiveUpdateSettings).toHaveBeenCalledOnce(); - expect(mocks.openSettings).not.toHaveBeenCalled(); - mocks.version = 35; - expect(supportsAndroidLiveUpdateSettings()).toBe(false); - mocks.os = "ios"; - mocks.version = 36; - expect(supportsAndroidLiveUpdateSettings()).toBe(false); - }); - - it.each([undefined, vi.fn(() => false)])( - "falls back to app settings for older binaries or missing system activities (%j)", - async (openLiveUpdateSettings) => { - if (openLiveUpdateSettings) mocks.native!.openLiveUpdateSettings = openLiveUpdateSettings; - const { openAndroidLiveUpdateSettings } = await import("./androidNotifications"); - await openAndroidLiveUpdateSettings(); - expect(mocks.openSettings).toHaveBeenCalledOnce(); - }, - ); - - it("uses the installed module and the build variant's deep-link scheme", async () => { - const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = - await import("./androidNotifications"); - const { supportsAgentAwarenessPush } = await import("./capabilities"); - mocks.config.extra.iosPersonalTeamBuild = true; - expect(supportsAgentAwarenessPush()).toBe(true); - configureAndroidAgentNotifications("device", "user", false); - expect(mocks.native?.configure).toHaveBeenCalledWith("device", "user", "t3code-preview", false); - clearAndroidAgentNotifications(); - expect(mocks.native?.clear).toHaveBeenCalledOnce(); - }); - - it.each([null, { clear: vi.fn() }, { configure: vi.fn() }])( - "disables push when the native binary is missing required methods (%j)", - async (native) => { - mocks.native = native; - const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = - await import("./androidNotifications"); - const { supportsAgentAwarenessPush } = await import("./capabilities"); - expect(supportsAgentAwarenessPush()).toBe(false); - expect(() => configureAndroidAgentNotifications("device", "user", true)).not.toThrow(); - expect(() => clearAndroidAgentNotifications()).not.toThrow(); - }, - ); - - it("preserves the iOS personal-team restriction without loading Android code", async () => { - mocks.os = "ios"; - const { supportsAgentAwarenessPush } = await import("./capabilities"); - expect(supportsAgentAwarenessPush()).toBe(true); - mocks.config.extra.iosPersonalTeamBuild = true; - expect(supportsAgentAwarenessPush()).toBe(false); - expect(mocks.requireModule).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/foregroundNotificationBehavior.test.ts b/apps/mobile/src/features/agent-awareness/foregroundNotificationBehavior.test.ts deleted file mode 100644 index a76391732239..000000000000 --- a/apps/mobile/src/features/agent-awareness/foregroundNotificationBehavior.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Notification } from "expo-notifications"; -import { describe, expect, it } from "vite-plus/test"; - -import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; -import { threadDeepLinkOnScreen } from "./notificationPayload"; - -function notificationWithData(data: Record): Notification { - return { - date: 0, - request: { identifier: "notification-1", content: { data }, trigger: null }, - } as unknown as Notification; -} - -describe("threadDeepLinkOnScreen", () => { - it("maps a thread route and its nested screens to the thread deep link", () => { - expect(threadDeepLinkOnScreen("/threads/env-1/thread-1")).toBe("/threads/env-1/thread-1"); - expect(threadDeepLinkOnScreen("/threads/env-1/thread-1/files/src")).toBe( - "/threads/env-1/thread-1", - ); - }); - - it("returns null outside a thread", () => { - expect(threadDeepLinkOnScreen("/")).toBeNull(); - expect(threadDeepLinkOnScreen("/settings")).toBeNull(); - expect(threadDeepLinkOnScreen("/threads/env-1")).toBeNull(); - }); -}); - -describe("foregroundNotificationBehavior", () => { - it("suppresses a notification for the thread already on screen", () => { - const behavior = foregroundNotificationBehavior( - notificationWithData({ environmentId: "env-1", threadId: "thread-1" }), - "/threads/env-1/thread-1", - ); - expect(behavior.shouldShowBanner).toBe(false); - expect(behavior.shouldShowList).toBe(false); - expect(behavior.shouldPlaySound).toBe(false); - }); - - it("shows a notification for another thread", () => { - const behavior = foregroundNotificationBehavior( - notificationWithData({ deepLink: "/threads/env-1/thread-2" }), - "/threads/env-1/thread-1", - ); - expect(behavior.shouldShowBanner).toBe(true); - expect(behavior.shouldShowList).toBe(true); - }); - - it("shows a notification when no thread is open or the payload has no target", () => { - expect( - foregroundNotificationBehavior( - notificationWithData({ environmentId: "env-1", threadId: "thread-1" }), - null, - ).shouldShowBanner, - ).toBe(true); - expect( - foregroundNotificationBehavior(notificationWithData({}), "/threads/env-1/thread-1") - .shouldShowBanner, - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index dd17b610bfad..a546aa76beaa 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -78,89 +78,6 @@ describe("liveActivityPreferences", () => { vi.clearAllMocks(); }); - it.effect("pushes disabled Live Activity preferences to relay registrations", () => - Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => true, - }); - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ - liveActivitiesEnabled: false, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: false, - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("pushes enabled Live Activity preferences to relay registrations", () => - Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: false, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => true, - }); - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("keeps local preferences refreshable when signed out", () => - Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: null, - connections: [connection], - canConfigureEnvironment: () => true, - }); - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ - liveActivitiesEnabled: false, - }); - expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("does not try to re-link managed relay connections without bearer credentials", () => { - const managedConnection: SavedRemoteConnection = { - ...connection, - bearerToken: null, - }; - - return Effect.gen(function* () { - yield* setLiveActivityUpdatesEnabled({ - enabled: true, - previousEnabled: false, - clerkToken: "clerk-token", - connections: [connection, managedConnection], - canConfigureEnvironment: () => true, - }); - - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); - it.effect("restores relay preferences when an environment update fails", () => { vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), @@ -223,28 +140,6 @@ describe("liveActivityPreferences", () => { }).pipe(Effect.provide(layerTest)); }); - it.effect("keeps device updates independently switchable without relay access", () => - Effect.gen(function* () { - for (const enabled of [false, true]) { - yield* setLiveActivityUpdatesEnabled({ - enabled, - previousEnabled: !enabled, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => false, - }); - } - - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { - liveActivitiesEnabled: false, - }); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { - liveActivitiesEnabled: true, - }); - expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("checks current relay access after updating the device registration", () => { let canConfigureEnvironment = true; vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() => @@ -266,35 +161,4 @@ describe("liveActivityPreferences", () => { expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); }).pipe(Effect.provide(layerTest)); }); - - it.effect("does not retry environment changes after relay access is revoked", () => { - let canConfigureEnvironment = true; - vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => - Effect.sync(() => { - canConfigureEnvironment = false; - }).pipe( - Effect.andThen( - Effect.fail(new CloudEnvironmentLinkError({ message: "relay access revoked" })), - ), - ), - ); - - return Effect.gen(function* () { - const exit = yield* Effect.exit( - setLiveActivityUpdatesEnabled({ - enabled: false, - previousEnabled: true, - clerkToken: "clerk-token", - connections: [connection], - canConfigureEnvironment: () => canConfigureEnvironment, - }), - ); - - expect(exit._tag).toBe("Failure"); - expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); - expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { - liveActivitiesEnabled: true, - }); - }).pipe(Effect.provide(layerTest)); - }); }); diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 2dd3ca03de29..92512bd2c3e8 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -26,46 +26,6 @@ afterEach(() => { }); describe("consumeLastAgentNotificationResponse", () => { - it("reports which initial-response operation failed", async () => { - const cause = new Error("notification lookup unavailable"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await consumeLastAgentNotificationResponse({ - getLastResponse: () => Promise.reject(cause), - clearLastResponse: () => Promise.resolve(), - handleResponse: vi.fn(), - }); - - expect(consoleError).toHaveBeenCalledWith( - expect.objectContaining({ - _tag: "NotificationNavigationError", - operation: "read", - }), - ); - }); - - it("routes a response before reporting a clear failure", async () => { - const cause = new Error("notification clear unavailable"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - const response = responseWithData({}, "notification-clear") as NotificationResponse; - const handleResponse = vi.fn(); - - await consumeLastAgentNotificationResponse({ - getLastResponse: () => Promise.resolve(response), - clearLastResponse: () => Promise.reject(cause), - handleResponse, - }); - - expect(handleResponse).toHaveBeenCalledWith(response); - expect(consoleError).toHaveBeenCalledWith( - expect.objectContaining({ - _tag: "NotificationNavigationError", - operation: "clear", - notificationId: "notification-clear", - }), - ); - }); - it("reports routing failures before clearing the response", async () => { const cause = new Error("notification routing unavailable"); const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -104,16 +64,6 @@ describe("extractAgentNotificationDeepLink", () => { ).toBe("/threads/env/thread"); }); - it("normalizes explicit thread deep links from APNs payload data", () => { - expect( - extractAgentNotificationDeepLink( - responseWithData({ - deepLink: "/threads/env%201/thread%2F2", - }), - ), - ).toBe("/threads/env%201/thread%2F2"); - }); - it("falls back to the thread route from environment and thread ids", () => { expect( extractAgentNotificationDeepLink( diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts deleted file mode 100644 index def7443d543e..000000000000 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, vi } from "vite-plus/test"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Notifications from "expo-notifications"; -import { requestAgentNotificationPermission } from "./notificationPermissions"; - -const platform = vi.hoisted(() => ({ OS: "android" })); -vi.mock("react-native", () => ({ Platform: platform })); -vi.mock("expo-notifications", () => ({ - AndroidImportance: { HIGH: 4 }, - setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)), - getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: false, canAskAgain: true })), - requestPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true, canAskAgain: true })), -})); - -describe("agent notification permission", () => { - beforeEach(() => { - vi.clearAllMocks(); - platform.OS = "android"; - }); - - it.effect("creates an Android channel before prompting for notification permission", () => - Effect.gen(function* () { - expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" }); - expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith("agent-alerts", { - name: "Agent alerts", - importance: 4, - }); - expect( - vi.mocked(Notifications.setNotificationChannelAsync).mock.invocationCallOrder[0], - ).toBeLessThan(vi.mocked(Notifications.requestPermissionsAsync).mock.invocationCallOrder[0]!); - }), - ); - - it.effect("preserves denied permission when Android cannot ask again", () => - Effect.gen(function* () { - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ - granted: false, - canAskAgain: false, - } as Notifications.NotificationPermissionsStatus); - expect(yield* requestAgentNotificationPermission).toEqual({ - type: "denied", - canAskAgain: false, - }); - expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled(); - }), - ); - - it.effect("keeps iOS permission requests free of Android channel setup", () => - Effect.gen(function* () { - platform.OS = "ios"; - expect(yield* requestAgentNotificationPermission).toEqual({ type: "granted" }); - expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled(); - expect(Notifications.requestPermissionsAsync).toHaveBeenCalledWith({ - ios: { allowAlert: true, allowBadge: true, allowSound: true }, - }); - }), - ); -}); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index a308b3d27cb9..cc6e14f44666 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -22,33 +22,23 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, - loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; -import type { Preferences } from "../../persistence/mobile-preferences"; -import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; +import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { - AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, - armAgentAwarenessLiveActivityForLocalWork, getAgentAwarenessRegistrationStatus, - mergeAgentAwarenessRegistrationPreferences, - refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, - shouldRegisterAgentAwarenessDeviceForProvider, unregisterAgentAwarenessConnection, updateAgentAwarenessRegistrationPreferences, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; -import { - configureAndroidAgentNotifications, - clearAndroidAgentNotifications, -} from "./androidNotifications"; +import { configureAndroidAgentNotifications } from "./androidNotifications"; vi.mock("./androidNotifications", () => ({ supportsAndroidAgentNotifications: vi.fn(() => true), @@ -277,39 +267,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { environmentConfigsMock.configs.clear(); }); - it("preserves disabled Live Activity preferences in relay registrations", () => { - expect( - makeRelayDeviceRegistrationRequest({ - deviceId: "device-1", - label: "Julius's iPhone", - iosMajorVersion: 18, - appVersion: "1.0.0", - pushToken: "apns-token", - pushToStartToken: "push-to-start-token", - notificationsEnabled: true, - preferences: { - liveActivitiesEnabled: false, - }, - }), - ).toEqual({ - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: "1.0.0", - pushToken: "apns-token", - pushToStartToken: "push-to-start-token", - preferences: { - liveActivitiesEnabled: false, - notificationsEnabled: true, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - }); - }); - it("sends the per-event notification rules saved on this device", () => { const request = makeRelayDeviceRegistrationRequest({ deviceId: "device-1", @@ -326,31 +283,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("registers the app's APNs routing so the relay targets the right bundle", () => { - expect( - makeRelayDeviceRegistrationRequest({ - deviceId: "device-1", - label: "Julius's iPhone", - iosMajorVersion: 18, - appVersion: "1.0.0", - bundleId: "com.t3tools.t3code.preview", - apsEnvironment: resolveApsEnvironment("preview"), - notificationsEnabled: true, - preferences: {}, - }), - ).toMatchObject({ - bundleId: "com.t3tools.t3code.preview", - apsEnvironment: "production", - }); - }); - - it("routes development builds to the APNs sandbox", () => { - expect(resolveApsEnvironment("development")).toBe("sandbox"); - expect(resolveApsEnvironment("preview")).toBe("production"); - expect(resolveApsEnvironment("production")).toBe("production"); - expect(resolveApsEnvironment(undefined)).toBe("production"); - }); - it("disables push features in Personal Team relay registrations", () => { Constants.expoConfig!.extra = { iosPersonalTeamBuild: true }; @@ -371,46 +303,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("marks notification delivery disabled when APNs permission is unavailable", () => { - expect( - makeRelayDeviceRegistrationRequest({ - deviceId: "device-1", - label: "Julius's iPhone", - iosMajorVersion: 18, - appVersion: "1.0.0", - pushToStartToken: "push-to-start-token", - notificationsEnabled: false, - preferences: { - liveActivitiesEnabled: true, - }, - }), - ).toEqual({ - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: "1.0.0", - pushToStartToken: "push-to-start-token", - preferences: { - liveActivitiesEnabled: true, - notificationsEnabled: false, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - }); - }); - - it("overrides persisted preferences for an in-flight registration", () => { - expect( - mergeAgentAwarenessRegistrationPreferences( - { liveActivitiesEnabled: false, baseFontSize: 18 }, - { liveActivitiesEnabled: true }, - ), - ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 }); - }); - it.effect("registers one push-token listener per Live Activity across wrappers", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); @@ -529,15 +421,24 @@ describe("makeRelayDeviceRegistrationRequest", () => { yield* Effect.promise(() => oldRequestArrived); tokenListener?.({ pushToken: "rotated-activity-token" }); - const listenerRegistration = yield* Effect.forkChild(runBackgroundOperations()); - for (let pass = 0; pass < 5; pass++) { - yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - } + const deviceIdLookups = vi.mocked(loadOrCreateAgentAwarenessDeviceId).mock.calls.length; + const rotated = backgroundRuntime.pending.shift()!; + // Starting immediately runs the listener's registration until it suspends: on the + // activity lock, or, without one, at its device-id lookup. + const rotatedRegistration = yield* Effect.forkChild( + Effect.exit( + rotated.operation as Effect.Effect, + ), + { startImmediately: true }, + ); + expect(vi.mocked(loadOrCreateAgentAwarenessDeviceId).mock.calls).toHaveLength( + deviceIdLookups, + ); expect(arrivals).toEqual(["activity-token"]); openOldRequest?.(); yield* Fiber.join(lookup); - yield* Fiber.join(listenerRegistration); + rotated.resolve(yield* Fiber.join(rotatedRegistration)); expect(arrivals).toEqual(["activity-token", "rotated-activity-token"]); }).pipe( Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), @@ -545,120 +446,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { ); }); - it.effect("preserves Live Activity push-token lookup failures", () => { - const cause = new Error("native token lookup failed"); - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.reject(cause)), - addPushTokenListener: vi.fn(), - }; - - return Effect.gen(function* () { - const error = yield* Effect.flip( - registerLiveActivityPushToken({ activity: activity as never }), - ); - - expect(error).toBeInstanceOf(AgentAwarenessOperationError); - expect(error).toMatchObject({ - _tag: "AgentAwarenessOperationError", - operation: "read-live-activity-push-token", - cause, - message: "Agent awareness operation read-live-activity-push-token failed.", - }); - }).pipe(Effect.provide(layerRelayTest)); - }); - - it.effect( - "reports Live Activity token registration as skipped when relay auth is unavailable", - () => { - registerAgentAwarenessConnection(savedConnection()); - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - }; - - return Effect.gen(function* () { - expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); - - it.effect( - "registers APNS-started Live Activities for relay updates without mutating them locally", - () => { - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - start: vi.fn(), - update: vi.fn(), - end: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* refreshActiveLiveActivityRemoteRegistration(); - - expect(activity.getPushToken).toHaveBeenCalled(); - expect(activity.start).not.toHaveBeenCalled(); - expect(activity.update).not.toHaveBeenCalled(); - expect(activity.end).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); - - it.effect( - "re-registers active Live Activity tokens when the app returns to the foreground", - () => { - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - activity.getPushToken.mockClear(); - - expect(appStateMock.listeners).toHaveLength(1); - for (const listener of appStateMock.listeners) { - listener("background"); - } - yield* runBackgroundOperations(); - expect(activity.getPushToken).not.toHaveBeenCalled(); - - for (const listener of appStateMock.listeners) { - listener("active"); - } - yield* runBackgroundOperations(); - expect(activity.getPushToken).toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); - - it.effect("dismisses ended Live Activities when the idle app returns to the foreground", () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - widgetMocks.dismissEnded.mockClear(); - - for (const listener of appStateMock.listeners) listener("background"); - yield* runBackgroundOperations(); - expect(widgetMocks.dismissEnded).not.toHaveBeenCalled(); - - for (const listener of appStateMock.listeners) listener("active"); - yield* runBackgroundOperations(); - - expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); - expect(widgetMocks.start).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect("still registers the active card when ended-card cleanup throws", () => { const registrations: unknown[] = []; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { @@ -728,18 +515,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(appStateMock.listeners).toHaveLength(0); }); - it.effect("refreshes APNs registration for connected environments after settings changes", () => { - registerAgentAwarenessConnection(savedConnection()); - return Effect.gen(function* () { - yield* runBackgroundOperations(); - vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); - - yield* refreshAgentAwarenessRegistration(); - - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect("registers the APNs device when cloud auth becomes available", () => { const fetchMock = vi.fn((request: RequestInfo | URL) => { const url = request instanceof Request ? request.url : String(request); @@ -798,60 +573,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); - it.effect("marks registration failed when device registration cannot complete", () => { - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockRejectedValueOnce( - new Error("registration failed"), - ); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* refreshAgentAwarenessRegistration(); - expect(getAgentAwarenessRegistrationStatus()).toBe("failed"); - }).pipe(Effect.provide(layerRelayTest)); - }); - - it("clears registration status on cloud sign-out", () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - setAgentAwarenessRelayTokenProvider(null); - expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); - expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); - }); - - it("releases the provider without ending activities or clearing the registration", () => { - const end = vi.fn(() => Promise.resolve()); - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - end, - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - registrationRecordStore.current = { identity: "", signature: "sig" }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - expect(appStateMock.listeners).toHaveLength(1); - - releaseAgentAwarenessRelayTokenProvider(); - - expect(appStateMock.listeners).toHaveLength(0); - expect(end).not.toHaveBeenCalled(); - expect(clearAgentAwarenessRegistrationRecord).not.toHaveBeenCalled(); - expect(registrationRecordStore.current).not.toBeNull(); - }); - - it.effect("resets a pending status to unknown when relay config is missing", () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(getAgentAwarenessRegistrationStatus()).toBe("unknown"); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect("keeps a registered status when a later refresh fails", () => { Constants.expoConfig!.extra = { relay: { @@ -893,100 +614,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); - it.effect("dedupes rapid activity-token re-registrations within the replay window", () => { - // Fetch counts are unreliable here (the module-level relay layer captures - // the first test's fetch), so assert on the flow's own seams: a real - // registration attempt loads the device id, a deduped one short-circuits - // before it. - const fetchMock = vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - const activity = { - getId: () => "activity-1", - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(activity.getPushToken).toHaveBeenCalled(); - - vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockClear(); - yield* refreshActiveLiveActivityRemoteRegistration(); - expect(loadOrCreateAgentAwarenessDeviceId).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }); - - it.effect("re-registers when the stored account identity differs", () => { - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - registrationRecordStore.current = { identity: "someone-else", signature: "stale" }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - return Effect.gen(function* () { - yield* refreshAgentAwarenessRegistration(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerRelayTest)); - }); - - it.effect("coalesces simultaneous sign-in and environment connection registrations", () => { - const fetchMock = vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - - vi.mocked(Notifications.getPermissionsAsync).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - registerAgentAwarenessConnection(savedConnection()); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect("continues queued device registration after a failed auth lookup", () => { Constants.expoConfig!.extra = { relay: { @@ -1011,50 +638,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); - it("only registers again when the authenticated identity changes", () => { - expect(shouldRegisterAgentAwarenessDeviceForProvider(null, "user-a")).toBe(true); - expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-a")).toBe(false); - expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-b")).toBe(true); - expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); - }); - - it.effect("registers rotated APNs tokens without rereading the native token", () => { - const fetchMock = vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - Constants.expoConfig!.extra = { - relay: { - url: "https://relay.example.test/", - }, - }; - - vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - - const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; - expect(tokenListener).toBeDefined(); - tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); - - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect( "keeps the user-scoped relay APNs device when an environment connection is removed", () => { @@ -1093,55 +676,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }, ); - - it("skips the Live Activity seed when the environment reports publishing disabled", async () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - vi.mocked(loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: true, - } as Preferences); - environmentConfigsMock.configs.set("env-1", { - environment: { capabilities: { agentActivityPublishing: false } }, - }); - - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: "env-1" as EnvironmentId, - threadTitle: "Fix the flaky test", - projectTitle: "t3code", - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(widgetMocks.start).not.toHaveBeenCalled(); - }); - - it("seeds the Live Activity for publishing and pre-capability environments", async () => { - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - environmentConfigsMock.configs.set("env-publishing", { - environment: { capabilities: { agentActivityPublishing: true } }, - }); - - vi.mocked(loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: true, - } as Preferences); - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: "env-publishing" as EnvironmentId, - threadTitle: "Fix the flaky test", - projectTitle: "t3code", - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(widgetMocks.start).toHaveBeenCalledTimes(1); - - widgetMocks.start.mockClear(); - vi.mocked(loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: true, - } as Preferences); - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: "env-pre-capability" as EnvironmentId, - threadTitle: "Fix the flaky test", - projectTitle: "t3code", - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(widgetMocks.start).toHaveBeenCalledTimes(1); - }); it.effect.each(["ios", "android"] as const)( "does not enable %s notifications when a token rotates after permission is revoked", (os) => { @@ -1240,88 +774,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(layerRelayTest)); }); - it.effect("registers an Android FCM token without invoking Apple Live Activities", () => { - vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); - vi.spyOn(Platform, "Version", "get").mockReturnValue(36); - vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ - type: "android", - data: "fcm-token", - }); - vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true } as Preferences); - vi.stubGlobal( - "fetch", - vi.fn((request: RequestInfo | URL) => { - const url = request instanceof Request ? request.url : String(request); - return Promise.resolve( - Response.json( - url.endsWith("/v1/client/dpop-token") - ? { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "mobile:registration", - } - : { ok: true }, - ), - ); - }), - ); - Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"), "user-a"); - return Effect.gen(function* () { - yield* refreshAgentAwarenessRegistration(); - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalled(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalled(); - expect(registrationRecordStore.current?.signature).toContain("fcm-token"); - expect(registrationRecordStore.current?.signature).toContain("android"); - expect(widgetMocks.getInstances).not.toHaveBeenCalled(); - expect(configureAndroidAgentNotifications).toHaveBeenCalledWith("device-1", "user-a", true); - vi.mocked(clearAndroidAgentNotifications).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-b"), "user-b"); - expect(clearAndroidAgentNotifications).toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }); - it.effect( - "preserves same-account Android notifications and replays on remount and later foreground", - () => { - vi.spyOn(Platform, "OS", "get").mockReturnValue("android"); - vi.spyOn(Platform, "Version", "get").mockReturnValue(36); - vi.mocked(Notifications.getDevicePushTokenAsync).mockResolvedValue({ - type: "android", - data: "fcm-token", - }); - Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; - const now = vi.spyOn(Date, "now").mockReturnValue(1000000); - vi.mocked(clearAndroidAgentNotifications).mockClear(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); - return Effect.gen(function* () { - yield* runBackgroundOperations(); - expect(getAgentAwarenessRegistrationStatus()).toBe("registered"); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); - yield* refreshAgentAwarenessRegistration(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(1); - releaseAgentAwarenessRelayTokenProvider(); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); - yield* runBackgroundOperations(); - expect(clearAndroidAgentNotifications).not.toHaveBeenCalled(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2); - for (const listener of appStateMock.listeners) listener("active"); - yield* runBackgroundOperations(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(2); - now.mockReturnValue(1060001); - for (const listener of appStateMock.listeners) listener("active"); - yield* runBackgroundOperations(); - expect(saveAgentAwarenessRegistrationRecord).toHaveBeenCalledTimes(3); - expect(clearAndroidAgentNotifications).not.toHaveBeenCalled(); - expect(widgetMocks.getInstances).not.toHaveBeenCalled(); - setAgentAwarenessRelayTokenProvider(null); - expect(clearAndroidAgentNotifications).toHaveBeenCalled(); - expect(clearAgentAwarenessRegistrationRecord).toHaveBeenCalled(); - }).pipe(Effect.provide(layerRelayTest)); - }, - ); - it.effect( "does not reconfigure or register a detached Android account from an in-flight operation", () => { diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts deleted file mode 100644 index 77e45963b831..000000000000 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { ArchivedSnapshotEntry } from "@t3tools/client-runtime/state/threads"; -import type { OrchestrationProjectShell, OrchestrationV2ThreadShell } from "@t3tools/contracts"; -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; -import * as DateTime from "effect/DateTime"; - -import { buildArchivedThreadGroups } from "./archivedThreadList"; -import { makeRawThreadShell } from "../../test-fixtures"; - -const environmentId = EnvironmentId.make("environment-1"); - -function makeProject( - input: Partial & Pick, -): OrchestrationProjectShell { - return { - workspaceRoot: `/workspaces/${input.id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - ...input, - }; -} - -function makeThread( - input: Pick & { - readonly branch?: string | null; - readonly archivedAt?: string | null; - }, -): OrchestrationV2ThreadShell { - const archivedAt = input.archivedAt === undefined ? "2026-06-02T00:00:00.000Z" : input.archivedAt; - return makeRawThreadShell({ - ...input, - archivedAt: archivedAt === null ? null : DateTime.makeUnsafe(archivedAt), - }); -} - -function makeSnapshot( - projects: ReadonlyArray, - threads: ReadonlyArray, - targetEnvironmentId = environmentId, -): ArchivedSnapshotEntry { - return { - environmentId: targetEnvironmentId, - snapshot: { - schemaVersion: 1, - snapshotSequence: 1, - projects, - threads, - }, - }; -} - -describe("buildArchivedThreadGroups", () => { - it("groups archived threads by project and sorts newest first", () => { - const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); - const older = makeThread({ - id: ThreadId.make("thread-older"), - projectId: project.id, - title: "Older", - }); - const newer = makeThread({ - archivedAt: "2026-06-03T00:00:00.000Z", - id: ThreadId.make("thread-newer"), - projectId: project.id, - title: "Newer", - }); - - const result = buildArchivedThreadGroups({ - snapshots: [makeSnapshot([project], [older, newer])], - environmentLabels: { [environmentId]: "Julius's MacBook Pro" }, - environmentId: null, - searchQuery: "", - sortOrder: "newest", - }); - - expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-newer", "thread-older"]); - }); - - it("filters by environment and matches project, thread, and branch text", () => { - const secondEnvironmentId = EnvironmentId.make("environment-2"); - const firstProject = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); - const secondProject = makeProject({ id: ProjectId.make("project-2"), title: "Website" }); - const firstThread = makeThread({ - branch: "fix/archive-screen", - id: ThreadId.make("thread-1"), - projectId: firstProject.id, - title: "Build settings route", - }); - const secondThread = makeThread({ - id: ThreadId.make("thread-2"), - projectId: secondProject.id, - title: "Unrelated", - }); - const snapshots = [ - makeSnapshot([firstProject], [firstThread]), - makeSnapshot([secondProject], [secondThread], secondEnvironmentId), - ]; - - const result = buildArchivedThreadGroups({ - snapshots, - environmentLabels: { - [environmentId]: "Local", - [secondEnvironmentId]: "Remote", - }, - environmentId, - searchQuery: "archive-screen", - sortOrder: "oldest", - }); - - expect(result).toHaveLength(1); - expect(result[0]?.project.environmentId).toBe(environmentId); - expect(result[0]?.threads.map((thread) => thread.id)).toEqual(["thread-1"]); - }); - - it("ignores non-archived entries returned in a snapshot", () => { - const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" }); - const active = makeThread({ - archivedAt: null, - id: ThreadId.make("thread-active"), - projectId: project.id, - title: "Active", - }); - - const result = buildArchivedThreadGroups({ - snapshots: [makeSnapshot([project], [active])], - environmentLabels: {}, - environmentId: null, - searchQuery: "", - sortOrder: "newest", - }); - - expect(result).toEqual([]); - }); -}); diff --git a/apps/mobile/src/features/browser/previewPromptText.test.ts b/apps/mobile/src/features/browser/previewPromptText.test.ts index 51fa7fe91bff..bb286a401fc8 100644 --- a/apps/mobile/src/features/browser/previewPromptText.test.ts +++ b/apps/mobile/src/features/browser/previewPromptText.test.ts @@ -9,30 +9,10 @@ describe("browser prompt response", () => { expect(previewPromptText(prompt, { ...prompt }, "Alice")).toBe("Alice"); }); - it("preserves an intentionally empty response", () => { - expect(previewPromptText(prompt, { ...prompt }, "")).toBe(""); - }); - - it("initializes a newly opened prompt", () => { - expect(previewPromptText(null, prompt, "")).toBe("Guest"); - }); - - it("resets a stale draft after reconnecting without dialog history", () => { - expect(previewPromptText(null, prompt, "Alice")).toBe("Guest"); - }); - it("resets when the message changes", () => { expect(previewPromptText(prompt, { ...prompt, message: "Next name?" }, "Alice")).toBe("Guest"); }); - it("resets when the default value changes", () => { - expect(previewPromptText(prompt, { ...prompt, defaultValue: "Bob" }, "Alice")).toBe("Bob"); - }); - - it("resets when the dialog type changes with matching message and default", () => { - expect(previewPromptText(prompt, { ...prompt, type: "confirm" }, "Alice")).toBe("Guest"); - }); - it("clears a closed dialog and initializes an identical reopened prompt", () => { const cleared = previewPromptText(prompt, null, "Alice"); expect(cleared).toBe(""); diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts deleted file mode 100644 index 8277463dae3a..000000000000 --- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; -import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; -import { describe, expect, it } from "vite-plus/test"; - -import { RELAY_TUNNEL_RELEASED_MESSAGE } from "@t3tools/client-runtime/relay"; - -import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; - -function relayStatus( - status: RelayEnvironmentStatusResponse["status"], - error?: string, - traceId?: string, -): RelayEnvironmentStatusResponse { - return { - environmentId: EnvironmentId.make("environment-cloud"), - endpoint: { - httpBaseUrl: "https://cloud.example.test/", - wsBaseUrl: "wss://cloud.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status, - checkedAt: "2026-06-05T16:49:11.000Z", - ...(error ? { error } : {}), - ...(traceId ? { traceId } : {}), - }; -} - -describe("available cloud environment presentation", () => { - it("shows an incompatible discovered server before any connection attempt", () => { - const onlineStatus = relayStatus("online"); - const status = { - ...onlineStatus, - descriptor: { - environmentId: onlineStatus.environmentId, - label: "Preview server", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "2.0.0", - orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, - capabilities: { repositoryIdentity: true }, - }, - } satisfies RelayEnvironmentStatusResponse; - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: false, - status, - statusError: null, - statusErrorTraceId: null, - }), - ).toMatchObject({ - connectionState: "unsupported", - statusText: expect.stringContaining("Update your app"), - }); - }); - - it("presents an online unsaved environment as available, not connected", () => { - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: false, - status: relayStatus("online"), - statusError: null, - statusErrorTraceId: null, - }), - ).toEqual({ - connectionError: null, - connectionErrorTraceId: null, - connectionState: "available", - statusText: "Available · Relay online", - }); - }); - - it("keeps relay status checks distinct from connection attempts", () => { - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: true, - status: null, - statusError: null, - statusErrorTraceId: null, - }), - ).toEqual({ - connectionError: null, - connectionErrorTraceId: null, - connectionState: "available", - statusText: "Available · Checking relay status...", - }); - }); - - it("surfaces an offline relay as an error", () => { - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: false, - status: relayStatus("offline", "Tunnel is unavailable.", "trace-offline"), - statusError: null, - statusErrorTraceId: null, - }), - ).toEqual({ - connectionError: "Tunnel is unavailable.", - connectionErrorTraceId: "trace-offline", - connectionState: "error", - statusText: "Tunnel is unavailable.", - }); - }); - - it("preserves trace metadata for relay request failures", () => { - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: false, - status: null, - statusError: "Could not get relay environment status.", - statusErrorTraceId: "trace-status", - }), - ).toMatchObject({ - connectionError: "Could not get relay environment status.", - connectionErrorTraceId: "trace-status", - }); - }); - - it("tells the user to update a host whose idle tunnel was removed", () => { - const status = { - ...relayStatus("offline", "Managed endpoint health request failed.", "trace-released"), - offlineReason: "tunnel_released", - } satisfies RelayEnvironmentStatusResponse; - expect( - availableCloudEnvironmentPresentation({ - isStatusPending: false, - status, - statusError: null, - statusErrorTraceId: null, - }), - ).toMatchObject({ - connectionState: "error", - statusText: RELAY_TUNNEL_RELEASED_MESSAGE, - connectionErrorTraceId: "trace-released", - }); - }); -}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 1e78f250ef4e..13860d4bea17 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -179,79 +179,6 @@ describe("mobile cloud link environment client", () => { }), ); - it.effect("preserves typed local environment failures while obtaining a link proof", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - return Promise.resolve( - Response.json( - { - _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", - }, - { status: 401 }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); - expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - }), - ); - - it.effect("preserves typed relay error bodies while linking environments", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - return Promise.resolve( - Response.json( - { - _tag: "RelayEnvironmentLinkProofInvalidError", - code: "environment_link_proof_invalid", - reason: "origin_not_allowed", - traceId: "trace-test", - }, - { status: 400 }, - ), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const error = yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", - traceId: "trace-test", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); - }), - ); - it.effect("rejects relay link credentials for a different managed endpoint provider", () => Effect.gen(function* () { const fetchMock = vi.fn((url: string | URL) => { @@ -341,41 +268,4 @@ describe("mobile cloud link environment client", () => { }); }), ); - - it.effect("enables Live Activities for both the link challenge and registration", () => - Effect.gen(function* () { - const bodies: Array> = []; - const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { - if (init?.body) { - bodies.push(JSON.parse(requestBodyText(init.body)) as Record); - } - if (String(url).endsWith("/v1/client/environment-link-challenges")) { - return Promise.resolve(Response.json(validLinkChallengeResponse())); - } - if (String(url).endsWith("/api/connect/link-proof")) { - return Promise.resolve(Response.json(validLinkProof())); - } - if (String(url).endsWith("/v1/client/environment-links")) { - return Promise.resolve(Response.json(validLinkResponse())); - } - return Promise.resolve( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkEnvironmentToCloudWithPreference({ - clerkToken: "clerk-token", - connection: savedConnection, - liveActivitiesEnabled: true, - }), - ); - - expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([ - expect.objectContaining({ liveActivitiesEnabled: true }), - expect.objectContaining({ liveActivitiesEnabled: true }), - ]); - }), - ); }); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts index 9642e5f63ae2..5a4befd99319 100644 --- a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -1,7 +1,5 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Logger from "effect/Logger"; -import * as SecureStore from "expo-secure-store"; import { vi } from "vite-plus/test"; const secureStore = vi.hoisted(() => new Map()); @@ -18,33 +16,7 @@ vi.mock("expo-secure-store", () => ({ }), })); -import { - ManagedRelayTokenStoreError, - managedRelayAccessTokenStore, -} from "./managedRelayTokenStore"; - -it.effect("round-trips and clears persisted managed relay access tokens", () => - Effect.gen(function* () { - secureStore.clear(); - const entries = [ - { - accountId: "user-1", - clientId: "t3-mobile", - relayUrl: "https://relay.example.test", - thumbprint: "thumbprint", - scopes: ["environment:connect"], - accessToken: "access-token", - expiresAtMillis: 1_800_000, - }, - ] as const; - - yield* managedRelayAccessTokenStore.save(entries); - expect(yield* managedRelayAccessTokenStore.load).toEqual(entries); - - yield* managedRelayAccessTokenStore.clear; - expect(yield* managedRelayAccessTokenStore.load).toEqual([]); - }), -); +import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; it.effect("falls back to an empty cache when persisted data is invalid", () => Effect.gen(function* () { @@ -54,32 +26,3 @@ it.effect("falls back to an empty cache when persisted data is invalid", () => expect(yield* managedRelayAccessTokenStore.load).toEqual([]); }), ); - -it.effect("logs structured storage failures before falling back to an empty cache", () => { - const messages: Array = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const cause = new Error("secure store unavailable"); - vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(cause); - - return Effect.gen(function* () { - expect(yield* managedRelayAccessTokenStore.load).toEqual([]); - - const message = messages.find( - (candidate) => - Array.isArray(candidate) && candidate[0] === "Managed relay token store operation failed.", - ); - expect(message).toBeDefined(); - const context = (message as ReadonlyArray)[1] as { - readonly cause: ManagedRelayTokenStoreError; - }; - expect(context.cause).toBeInstanceOf(ManagedRelayTokenStoreError); - expect(context.cause).toMatchObject({ - operation: "read", - storageKey: "t3code.cloud.relay-access-tokens", - cause, - }); - expect(context.cause.message).not.toContain(cause.message); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); -}); diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 05bf1a8fbccd..8c09819aa72f 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { - CloudPublicConfigMissingError, - hasTracingPublicConfig, - resolveCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "./publicConfig"; +import { resolveCloudPublicConfig } from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -16,56 +11,6 @@ vi.mock("expo-constants", () => ({ })); describe("resolveCloudPublicConfig", () => { - it("reports the missing Clerk JWT template as structured configuration", () => { - expect(() => resolveRelayClerkTokenOptions()).toThrowError( - new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), - ); - }); - - it("returns no cloud configuration for an unconfigured build", () => { - expect(resolveCloudPublicConfig({})).toEqual({ - clerk: { - publishableKey: null, - jwtTemplate: null, - }, - relay: { - url: null, - }, - observability: { - tracesUrl: null, - tracesDataset: null, - tracesToken: null, - }, - }); - }); - - it("normalizes statically injected cloud configuration", () => { - expect( - resolveCloudPublicConfig({ - clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, - relay: { url: " https://relay.example.test/// " }, - observability: { - tracesUrl: " https://api.axiom.co/v1/traces ", - tracesDataset: " mobile-traces ", - tracesToken: " public-ingest-token ", - }, - }), - ).toEqual({ - clerk: { - publishableKey: "pk_test_example", - jwtTemplate: "t3-relay", - }, - relay: { - url: "https://relay.example.test", - }, - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }); - }); - it("rejects an insecure relay URL", () => { expect( resolveCloudPublicConfig({ @@ -87,45 +32,4 @@ describe("resolveCloudPublicConfig", () => { }, }); }); - - it("rejects an insecure traces URL", () => { - expect( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "http://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }).observability, - ).toEqual({ - tracesUrl: null, - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }); - }); - - it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - }, - }), - ), - ).toBe(false); - expect( - hasTracingPublicConfig( - resolveCloudPublicConfig({ - observability: { - tracesUrl: "https://api.axiom.co/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - }), - ), - ).toBe(true); - }); }); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 47a45c80abf4..d4b2eb3c6a48 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,7 +1,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { ConnectionTraceId } from "./ConnectionTraceId"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { hasSavedBearerRoute } from "@t3tools/client-runtime/connection"; +import { hasSavedBearerRoute, hostFreezeNotice } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -67,6 +67,9 @@ export function ConnectionEnvironmentRow(props: { const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; const hasConnectionFailure = enabled && props.environment.connectionError !== null; + const freeze = enabled + ? hostFreezeNotice(props.environment.environmentLabel, serverConfig?.lastEventLoopStall) + : null; const isRetrying = enabled && (props.environment.connectionState === "connecting" || @@ -151,6 +154,14 @@ export function ConnectionEnvironmentRow(props: { ) : null} ) : null} + {freeze ? ( + + {`${freeze.title}. ${freeze.description}`} + + ) : null} , "environmentId"> & { @@ -35,17 +35,6 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } -describe("relayManagedEnvironmentIds", () => { - it("leaves out a backend that was saved directly", () => { - const ids = relayManagedEnvironmentIds([ - connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), - connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), - ]); - - expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); - }); -}); - describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -90,39 +79,6 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); - it("keeps an available saved relay environment as a fallback when listing is unavailable", () => { - const cloud = connectedEnvironment({ - environmentId: "environment-cloud", - isRelayManaged: true, - connectionState: "available", - }); - - const sections = splitEnvironmentSections({ - connectedEnvironments: [cloud], - cloudEnvironments: null, - }); - - expect(sections.connectedCloudEnvironments).toEqual([cloud]); - expect(sections.availableCloudEnvironments).toEqual([]); - }); - - it("does not duplicate a saved relay environment in the available cloud listing", () => { - const cloud = connectedEnvironment({ - environmentId: "environment-cloud", - isRelayManaged: true, - connectionState: "available", - }); - const listedCloud = cloudEnvironment("environment-cloud"); - - const sections = splitEnvironmentSections({ - connectedEnvironments: [cloud], - cloudEnvironments: [listedCloud], - }); - - expect(sections.connectedCloudEnvironments).toEqual([cloud]); - expect(sections.availableCloudEnvironments).toEqual([]); - }); - it("still offers a cloud environment saved directly as a local backend", () => { const local = connectedEnvironment({ environmentId: "environment-cloud", @@ -140,38 +96,4 @@ describe("mobile environment settings sections", () => { sections.availableCloudEnvironments.map((environment) => environment.environmentId), ).toEqual([EnvironmentId.make("environment-cloud")]); }); - - it("keeps failed relay environments in the local connection row", () => { - const cloud = connectedEnvironment({ - environmentId: "environment-cloud", - isRelayManaged: true, - connectionState: "error", - connectionError: "Connection failed.", - }); - - const sections = splitEnvironmentSections({ - connectedEnvironments: [cloud], - cloudEnvironments: [cloudEnvironment("environment-cloud")], - }); - - expect(sections.connectedCloudEnvironments).toEqual([cloud]); - expect(sections.availableCloudEnvironments).toEqual([]); - }); - - it("offers T3 Connect for a machine saved only over the LAN, as an added route", () => { - const local = connectedEnvironment({ - environmentId: "environment-desk", - isRelayManaged: false, - }); - - const sections = splitEnvironmentSections({ - connectedEnvironments: [local], - cloudEnvironments: [cloudEnvironment("environment-desk")], - }); - - expect(sections.localEnvironments).toEqual([local]); - expect( - sections.availableCloudEnvironments.map((environment) => environment.environmentId), - ).toEqual([EnvironmentId.make("environment-desk")]); - }); }); diff --git a/apps/mobile/src/features/connection/environmentStatusText.test.ts b/apps/mobile/src/features/connection/environmentStatusText.test.ts deleted file mode 100644 index e573138beef3..000000000000 --- a/apps/mobile/src/features/connection/environmentStatusText.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { orchestrationProtocolCompatibilityError } from "@t3tools/client-runtime/connection"; - -import { environmentStatusText } from "./environmentStatusText"; - -function compatibilityMessage(serverProtocolVersion: number): string { - const error = orchestrationProtocolCompatibilityError({ - environmentId: EnvironmentId.make("environment-preview"), - label: "Studio Mac", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "1.0.0", - orchestrationProtocolVersion: serverProtocolVersion, - capabilities: { repositoryIdentity: true }, - }); - if (error === null) throw new Error("expected an incompatible protocol"); - return error.message; -} - -describe("environment status text", () => { - it.each([ - ["a newer server", ORCHESTRATION_PROTOCOL_VERSION + 1, "Update your app"], - ["an older server", ORCHESTRATION_PROTOCOL_VERSION - 1, "Update T3 Code on Studio Mac"], - ])("tells the user what to update for %s", (_, serverProtocolVersion, guidance) => { - const text = environmentStatusText({ - phase: "unsupported", - error: compatibilityMessage(serverProtocolVersion), - traceId: null, - }); - expect(text).toContain(guidance); - }); - - it("falls back to the generic label when there is no detail", () => { - expect(environmentStatusText({ phase: "unsupported", error: null, traceId: null })).toBe( - "Client not supported", - ); - }); -}); diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..466fbfa7a282 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - buildPairingUrl, - extractPairingUrlFromQrPayload, - PairingQrPayloadEmptyError, - parsePairingUrl, -} from "./pairing"; +import { buildPairingUrl, extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; describe("buildPairingUrl", () => { it("uses HTTP for a schemeless IP address", () => { @@ -28,12 +23,6 @@ describe("buildPairingUrl", () => { }); describe("extractPairingUrlFromQrPayload", () => { - it("trims raw pairing urls from qr payloads", () => { - expect( - extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "), - ).toBe("https://remote.example.com/pair#token=pairing-token"); - }); - it("unwraps mobile deep links that carry an encoded pairing url", () => { expect( extractPairingUrlFromQrPayload( @@ -41,13 +30,6 @@ describe("extractPairingUrlFromQrPayload", () => { ), ).toBe("https://remote.example.com/pair#token=pairing-token"); }); - - it("rejects empty qr payloads", () => { - expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); - expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( - "Scanned QR code did not contain a pairing URL.", - ); - }); }); describe("parsePairingUrl", () => { diff --git a/apps/mobile/src/features/devices/device-stream-document.test.ts b/apps/mobile/src/features/devices/device-stream-document.test.ts index cd833a75511c..06e63d841561 100644 --- a/apps/mobile/src/features/devices/device-stream-document.test.ts +++ b/apps/mobile/src/features/devices/device-stream-document.test.ts @@ -1,25 +1,9 @@ import * as NodeVM from "node:vm"; import { describe, expect, it, vi } from "vite-plus/test"; -import { - deviceStreamBaseUrl, - deviceStreamDocument, - deviceStreamMessage, -} from "./device-stream-document"; +import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; describe("native device stream document", () => { - it.each([ - ["android", "ios", "http://192.168.1.10:3773/api/device-hub", "http://192.168.1.10:3773/"], - ["android", "ios", "https://env.example/t3/api/device-hub", "https://env.example/"], - ["android", "android", "http://192.168.1.10:3773/api/device-hub", "https://localhost/"], - ["ios", "ios", "http://192.168.1.10:3773/api/device-hub", "file:///"], - ] as const)( - "uses a %s viewer base URL for %s targets on %s", - (viewer, target, httpBase, expected) => { - expect(deviceStreamBaseUrl(viewer, target, httpBase)).toBe(expected); - }, - ); - it("keeps ticket and device values from terminating the embedded script", () => { const deviceId = ''; const configuration = JSON.stringify({ deviceId, ticket: "" }); @@ -33,12 +17,6 @@ describe("native device stream document", () => { expect(input).toEqual({ deviceId, ticket: "" }); }); - it("preserves script strings containing an HTML closing delimiter", () => { - const html = deviceStreamDocument("{}", 'var text="";'); - expect(html.match(/<\/script>/g)).toHaveLength(1); - expect(html).toContain('var text="<\\/script>";'); - }); - it("reports a bootstrap exception to the native recovery UI", () => { const postMessage = vi.fn(); const html = deviceStreamDocument( @@ -54,45 +32,9 @@ describe("native device stream document", () => { detail: "Device viewer stopped unexpectedly.", }); }); - - it.each(["error", "unhandledrejection"])("reports later %s failures to native", (event) => { - const postMessage = vi.fn(); - const listeners = new Map void>(); - const html = deviceStreamDocument("{}", "var T3DeviceStream={start(){}};"); - NodeVM.runInNewContext(html.match(/ + + + + + ); +} + +function NoVisibleFiles({ files }: { readonly files: ChangedFiles }) { + const { diff, filter, parsed } = files; + if (parsed.kind === "files") { + return parsed.files.length === 0 && filter.hiddenCount > 0 ? ( + } + variant="plain" + /> + ) : null; + } + return !diff.isPending && !diff.error ? ( + + No text changes available. + + ) : null; +} + +function FilesFooter({ files }: { readonly files: ChangedFiles }) { + const { diff, viewed, parsed } = files; + return ( + <> + {diff.nextCursor ? ( + , - Linking: { openURL: vi.fn() }, -})); -vi.mock("expo-image", () => ({ Image: "img" })); -vi.mock("../../components/ui/AppText", () => ({ AppText: "span" })); -vi.mock("../../state/assets", () => ({ useAssetUrl: () => null })); - -import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; - -describe("QuestionAnswerHistory", () => { - it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( - "renders attachment-only questions alongside text answers: %j", - (answers) => { - const markup = renderToStaticMarkup( - , - ); - expect(markup.match(/Provide a spec/g)).toHaveLength(1); - expect(markup.match(/spec\.txt/g)).toHaveLength(1); - expect(markup).toContain("Provide a screenshot"); - expect(markup).toContain("shot.png"); - for (const answer of Object.values(answers)) expect(markup).toContain(answer); - }, - ); -}); diff --git a/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx new file mode 100644 index 000000000000..c4db6d7a9375 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadBackgroundWorkSheet.tsx @@ -0,0 +1,143 @@ +import { useAtomValue } from "@effect/atom-react"; +import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + presentPendingBackgroundWork, + type PendingBackgroundWorkItem, +} from "@t3tools/client-runtime/state/thread-execution"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { ScreenStack } from "react-native-screens"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidSheetHeader } from "../../components/ui/AndroidScreenHeader"; +import { SymbolView } from "../../components/ui/AppSymbol"; +import { AppText as Text } from "../../components/ui/AppText"; +import { ScreenContent } from "../../components/ui/ScreenContent"; +import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { NativeHeader, NativeScreen } from "../../native/ThemedScreenStack"; +import { environmentThreadShells } from "../../state/threads"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; + +const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); +const KIND_LABELS = { + subagent: "Subagent", + command: "Command", + monitor: "Monitor", + background_task: "Background task", +} as const; + +type BackgroundWorkTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; + +export function ThreadBackgroundWorkSheet({ route }: StaticScreenProps) { + const { layout } = useAdaptiveWorkspaceLayout(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const target = route.params; + const thread = useAtomValue( + environmentThreadShells.threadShellAtom(scopeThreadRef(target.environmentId, target.threadId)), + ); + const presentation = presentPendingBackgroundWork(thread?.pendingBackgroundTasks ?? []); + const openThread = (threadId: ThreadId) => { + void Haptics.selectionAsync(); + // A thread replaces the leaf sheet so workspace back navigation still works. + navigation.dispatch( + StackActions.replace("Thread", { environmentId: target.environmentId, threadId }), + ); + }; + const content = ( + + {presentation === null ? ( + + Nothing running in the background. + + ) : ( + presentation.items.map((item) => ( + + )) + )} + + ); + if (Platform.OS === "ios") { + return ( + + + + {content} + + + + + ); + } + return ( + + + navigation.goBack()} /> + {content} + + ); +} + +function BackgroundWorkRow(props: { + readonly item: PendingBackgroundWorkItem; + readonly onOpen: (threadId: ThreadId) => void; +}) { + const { item } = props; + const childThreadId = item.childThreadId; + const row = ( + + + + {item.label} + {KIND_LABELS[item.kind]} + + {childThreadId === undefined ? null : ( + + )} + + ); + return childThreadId === undefined ? ( + {row} + ) : ( + props.onOpen(childThreadId)} + className="active:opacity-70" + > + {row} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 8df026aa8f20..c7a0a0aa93d7 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentThread, @@ -85,12 +86,8 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { GlassSurface } from "../../components/ui/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { fileRoutePathSegments } from "../files/filePath"; -import { - ComposerActionButton, - ComposerInlineControl, - ComposerToolbarRow, -} from "../../components/ComposerToolbar"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ComposerActionButton, ComposerToolbarRow } from "../../components/ComposerToolbar"; +import { ComposerModelControl } from "../../components/ComposerModelControl"; import { composerStripAttachments, type DraftComposerFileAttachment, @@ -209,12 +206,17 @@ export interface ThreadComposerProps { readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; - readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; + /** `stillSendable` reports whether the draft is unchanged after any wait before sending. */ + readonly onSendMessage: ( + followUp?: ActiveTurnComposerAction, + stillSendable?: () => boolean, + ) => Promise; readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly canSwitchProvider: boolean; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + /** True while the draft is stacked above the controls rather than in the one-row capsule. */ readonly onExpandedChange?: (expanded: boolean) => void; readonly onEditorFocusChange?: (focused: boolean) => void; } @@ -248,6 +250,11 @@ const composerHeightTransition: LayoutAnimationFunction = (values) => { }; export const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; +// Share of the one-row composer the draft keeps; a long model name truncates first. +const INLINE_EDITOR_MIN_WIDTH = "40%"; +// The one-row editor's horizontal padding plus room for the caret. +const INLINE_EDITOR_TEXT_SLACK = 10; +const INLINE_EDITOR_HEIGHT = 36; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" @@ -374,6 +381,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const pendingPastedTextAttachmentCountRef = useRef(0); + const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: inputRef, @@ -556,14 +564,46 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), onChangeSelection: composerMenu.onSelectionChange, }); + const isSubmissionLocked = queuedEdit?.saving === true || voiceInput.blocksSubmission; const voicePresentation = resolveVoiceComposerPresentation( voiceInput.state, voiceInput.elapsedSeconds, ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; - const showsCompactDictation = isVoiceInputPresented && !isExpanded; - const isToolbarVisible = isExpanded || isVoiceInputPresented; + const { setComposerEditing } = useAdaptiveWorkspaceLayout(); + // The composer stays one row, with the model picker in it, until the draft would + // wrap. Then the draft stacks above the controls and returns to the row once it + // fits again. The native editor reports its laid-out text size: more than one + // line means the draft wrapped where it is, and the widest line is a one-line + // draft's natural width, which is compared with the row's text area so a draft + // laid out in the wider card still returns to the row when it fits, including + // when the pane widens. The measurements live in refs and only the verdict is + // state, so a keystroke that keeps the verdict costs no extra render. + const draftSizeRef = useRef({ width: 0, height: 0 }); + const rowGeometryRef = useRef({ surfaceWidth: 0, leadingWidth: 0, trailingWidth: 0 }); + const [draftOverflowsRow, setDraftOverflowsRow] = useState(false); + const lineHeight = bodyText.lineHeight; + const updateDraftOverflow = () => { + const { width, height } = draftSizeRef.current; + const row = rowGeometryRef.current; + const rowTextWidth = + row.surfaceWidth - row.leadingWidth - row.trailingWidth - INLINE_EDITOR_TEXT_SLACK; + setDraftOverflowsRow( + height > lineHeight * 1.5 || (row.surfaceWidth > 0 && width > rowTextWidth), + ); + }; + const measureRow = (key: keyof typeof rowGeometryRef.current, value: number) => { + if (rowGeometryRef.current[key] === value) return; + rowGeometryRef.current = { ...rowGeometryRef.current, [key]: value }; + updateDraftOverflow(); + }; + const usesCard = isExpanded && draftMessage.length > 0 && draftOverflowsRow; + const showsCompactDictation = isVoiceInputPresented && !usesCard; + const isToolbarVisible = usesCard || isVoiceInputPresented; + const hasEditingAttachments = + (queuedEdit !== null && queuedEdit.existingAttachments.length > 0) || + stripAttachments.length > 0; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: draftAttachments, @@ -581,13 +621,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps (props.canOperateThread || props.connectionState !== "connected") && hasContent && !contextImports[composerDraftKey] && - !voiceInput.blocksSubmission && + !isSubmissionLocked && sendBlockedReason === null && !modelUnavailable; useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + onExpandedChange?.(usesCard); + }, [usesCard, onExpandedChange]); + useEffect(() => () => setComposerEditing(false), [setComposerEditing]); const onPressPreview = (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -620,16 +661,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = () => { setIsFocused(true); - onExpandedChange?.(true); onEditorFocusChange?.(true); + setComposerEditing(true); }; const handleBlur = () => { setIsFocused(false); - if (!settingsSheetPresentation.keepsComposerExpanded) { - onExpandedChange?.(false); - } onEditorFocusChange?.(false); + setComposerEditing(false); }; const messageDrafts = props.messageDrafts ?? null; const sendMenuItems = @@ -675,7 +714,12 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps } }; const handleSend = async (followUp?: ActiveTurnComposerAction) => { - if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; + if ( + isSubmissionLocked || + pendingPastedTextAttachmentCountRef.current > 0 || + pendingNativeImagePasteCountRef.current > 0 + ) + return; if (usageLimitsOffered && isUsageLimitsCommand(draftMessage) && draftAttachments.length === 0) { if (openUsageLimits()) onChangeDraftMessage(""); return; @@ -683,7 +727,20 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); - const sent = await settlePromise(() => onSendMessage(followUp)); + const draftAtSend = getComposerDraftSnapshot(composerDraftKey); + const stillSendable = () => { + const draft = getComposerDraftSnapshot(composerDraftKey); + return ( + pendingPastedTextAttachmentCountRef.current === 0 && + pendingNativeImagePasteCountRef.current === 0 && + draft.text === draftAtSend.text && + draft.attachments.length === draftAtSend.attachments.length && + draft.attachments.every( + (attachment, index) => attachment.id === draftAtSend.attachments[index]?.id, + ) + ); + }; + const sent = await settlePromise(() => onSendMessage(followUp, stillSendable)); inFlightThreadIdsRef.current.delete(threadKey); if (AsyncResult.isFailure(sent)) { // The composer state already surfaces send errors; only the lock needed releasing. @@ -729,6 +786,8 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ...(lockedProviderInstanceId ? { lockedProviderInstanceId } : {}), providerGroups: threadProviderGroups, selectedModel: currentModelSelection, + prompt: draftMessage, + onPromptChange: (text) => setComposerDraftText(composerDraftKey, text), reportedModelSelection: props.reportedModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(withRememberedModelOptions(option.selection)), @@ -786,12 +845,76 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); + // Removable attachments: inside the card, or above the focused one-row composer. + const editingAttachments = hasEditingAttachments ? ( + <> + {queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( + + + + ) : null} + {stripAttachments.length > 0 ? ( + + undefined : props.onRemoveDraftImage} + onPressPreview={isSubmissionLocked ? undefined : onPressPreview} + onPressVideo={isSubmissionLocked ? undefined : onPressVideo} + onPressDocument={ + isSubmissionLocked + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } + /> + + ) : null} + + ) : null; + + // The row and the card's toolbar share this picker. It shrinks to the provider + // icon in the row once the draft has text, so the draft keeps the width. + const modelControl = ( + + 0} + showSummary={usesCard} + onPress={openSettings} + /> + + ); + return ( measureRow("surfaceWidth", event.nativeEvent.layout.width)} > - {!voiceInput.isBusy && + {!isSubmissionLocked && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request" || @@ -859,10 +983,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps + {isExpanded && !usesCard ? editingAttachments : null} - - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - onPress={openSettings} - /> - + {modelControl} )} @@ -1212,6 +1314,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps state={voiceInput.state} presentation={voicePresentation} isAvailable={voiceInput.isAvailable} + disabled={isSubmissionLocked} onStart={voiceInput.start} onConfirm={voiceInput.stop} onCancel={voiceInput.cancel} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 50530d77ec9b..8c36592faaf7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,8 @@ -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; @@ -8,7 +12,13 @@ import { useThreadShell, } from "../../state/entities"; import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; -import { StackActions, useNavigation } from "@react-navigation/native"; +import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; +import { + effectiveSnoozed, + threadWokeAt, + isThreadWokeUnseen, + snoozeWakeLabel, +} from "@t3tools/client-runtime/state/thread-settled"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; @@ -30,7 +40,6 @@ import type { EnvironmentId, MessageId, ModelSelection, - ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, RuntimeRequestId, @@ -43,7 +52,11 @@ import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/shared/codexArtifactTemplates"; -import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; +import { + declinePendingApprovals, + type ApprovalResponder, + type ThreadUserInputQuestion, +} from "@t3tools/client-runtime/state/thread-requests"; import { presentPendingBackgroundWork, presentProviderGoal, @@ -194,6 +207,7 @@ export interface ThreadDetailScreenProps { | { readonly kind: "failed"; readonly reason: string; readonly onEditTask: () => void } | null; readonly activePendingApproval: PendingApproval | null; + readonly activePendingApprovals: ReadonlyArray; readonly respondingApprovalId: RuntimeRequestId | null; readonly activePendingUserInput: PendingUserInput | null; readonly activePendingUserInputDrafts: Record; @@ -231,7 +245,10 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; /** Present while the thread's queue is held. */ readonly onResumeQueue: (() => Promise) | null; - readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; + readonly onSendMessage: ( + followUp?: ActiveTurnComposerAction, + beforeDispatch?: () => Promise, + ) => Promise; readonly onReconnectEnvironment: () => void; /** Offers to stop what the settled thread still runs. */ readonly onStopBackgroundWork?: () => void; @@ -240,10 +257,7 @@ export interface ThreadDetailScreenProps { readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; - readonly onRespondToApproval: ( - requestId: RuntimeRequestId, - decision: ProviderApprovalDecision, - ) => Promise; + readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( requestId: RuntimeRequestId, question: ThreadUserInputQuestion, @@ -355,7 +369,25 @@ function ThreadVisitWatermark(props: { const lastDispatchedVisitRef = useRef(null); const lastVisitDispatchRef = useRef({ threadKey: selectedThreadKey, at: 0 }); const selectedThreadId = thread.id; - const selectedThreadUpdatedAt = thread.updatedAt; + const snoozedUntil = thread.snoozedUntil; + // Keep the clock in state so React Compiler invalidates the wake calculation. + const [wakeNow, setWakeNow] = useState(() => new Date().toISOString()); + useEffect(() => { + if (!showContent || snoozedUntil == null) return; + const delay = Date.parse(snoozedUntil) - Date.now(); + if (!Number.isFinite(delay)) return; + const timer = setTimeout(() => setWakeNow(new Date().toISOString()), Math.max(0, delay) + 50); + return () => clearTimeout(timer); + }, [showContent, snoozedUntil]); + const selectedThreadWokeAt = threadWokeAt(thread, { + now: wakeNow, + }); + // A scheduled wake can happen without a newer server update. + const selectedThreadUpdatedAt = + selectedThreadWokeAt !== null && Date.parse(selectedThreadWokeAt) > Date.parse(thread.updatedAt) + ? selectedThreadWokeAt + : thread.updatedAt; + const wakeIsLatestUpdate = selectedThreadUpdatedAt === selectedThreadWokeAt; const selectedThreadLastVisitedAt = thread.lastVisitedAt; const selectedThreadCompletedAt = thread.latestRun?.completedAt; useEffect(() => { @@ -382,7 +414,14 @@ function ThreadVisitWatermark(props: { (Number.isNaN(lastVisitedAtMs) || completedAtMs > lastVisitedAtMs); const previous = lastVisitDispatchRef.current; const elapsed = Date.now() - previous.at; - if (previous.threadKey !== selectedThreadKey || hasUnseenCompletion || elapsed >= 10_000) { + // Like a completion, a wake the user is looking at must not wait out the throttle: + // leaving within it would cancel the dispatch and keep Woke in the list. + if ( + previous.threadKey !== selectedThreadKey || + hasUnseenCompletion || + wakeIsLatestUpdate || + elapsed >= 10_000 + ) { dispatch(); return; } @@ -397,6 +436,7 @@ function ThreadVisitWatermark(props: { selectedThreadUpdatedAt, showContent, visitThread, + wakeIsLatestUpdate, ]); return null; } @@ -531,6 +571,96 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai } })(); const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); + const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { + reportFailure: false, + }); + const unsnoozeThread = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); + const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); + const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); + const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); + const statusPending = statusRequest?.threadKey === selectedThreadKey; + const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); + useFocusEffect( + useCallback(() => { + setLifecycleNow(new Date().toISOString()); + const timer = setInterval(() => setLifecycleNow(new Date().toISOString()), 60_000); + return () => clearInterval(timer); + }, []), + ); + const snoozed = + props.serverConfig?.environment.capabilities.threadSnooze === true && + effectiveSnoozed(props.selectedThread, { now: lifecycleNow }); + const snoozedUntil = props.selectedThread.snoozedUntil; + useEffect(() => { + if (!snoozed || snoozedUntil === null) return; + const timer = setTimeout( + () => setLifecycleNow(new Date().toISOString()), + Math.min(Math.max(0, Date.parse(snoozedUntil) - Date.now()) + 50, 2_147_483_647), + ); + return () => clearTimeout(timer); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time. + }, [snoozed, snoozedUntil, lifecycleNow]); + const settled = + props.serverConfig?.environment.capabilities.threadSettlement === true && + props.selectedThread.settledOverride === "settled"; + const wokeAt = + props.serverConfig?.environment.capabilities.threadSnooze === true + ? threadWokeAt(props.selectedThread, { now: lifecycleNow }) + : null; + const woke = isThreadWokeUnseen({ + wokeAt, + settled, + lastVisitedAt: props.selectedThread.lastVisitedAt, + latestRunCompletedAt: props.selectedThread.latestRun?.completedAt ?? null, + }); + const runStatusAction = async (action: "stop" | "wake" | "unsettle" | "dismiss") => { + if (statusPending || !props.canOperateThread) return; + const request = { threadKey: selectedThreadKey }; + setStatusRequest(request); + const target = { + environmentId: props.environmentId, + input: { threadId: props.selectedThread.id }, + }; + const recovery = props.selectedThread.limitRecovery; + const recoveryOwnsSnooze = + recovery?.snooze === true && + recovery.runId === props.selectedThread.latestRun?.runId && + snoozedUntil !== null && + Date.parse(snoozedUntil) === Date.parse(recovery.resetAt); + const result = + action === "stop" + ? await interruptThreadTurn(target) + : action === "wake" + ? recoveryOwnsSnooze + ? await updateMetadata({ + ...target, + input: { + ...target.input, + limitRecovery: { + runId: recovery.runId, + resetAt: recovery.resetAt, + snooze: false, + }, + }, + }) + : await unsnoozeThread({ ...target, input: { ...target.input, reason: "user" } }) + : action === "unsettle" + ? await unsettleThread({ ...target, input: { ...target.input, reason: "user" } }) + : await visitThread({ + ...target, + input: { ...target.input, visitedAt: wokeAt ?? lifecycleNow }, + }); + // An accepted interrupt can leave tasks running. Release the action for retry. + setStatusRequest((current) => (current === request ? null : current)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + "Could not update thread", + error instanceof Error ? error.message : "Try again.", + ); + } + }; const pendingBackgroundWork = presentPendingBackgroundWork( props.selectedThread.pendingBackgroundTasks, ); @@ -591,9 +721,46 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai .map((item) => item.label) .join(", ")}`, waiting: pendingBackgroundWork.waiting, - ...(props.onStopBackgroundWork === undefined - ? {} - : { onPress: props.onStopBackgroundWork }), + onPress: () => { + Keyboard.dismiss(); + navigation.navigate("ThreadBackgroundWork", { + environmentId: props.environmentId, + threadId: props.selectedThread.id, + }); + }, + action: { + label: statusPending ? "Stopping..." : "Stop", + accessibilityLabel: "Stop background work", + disabled: !props.canOperateThread || statusPending, + onPress: props.onStopBackgroundWork ?? (() => void runStatusAction("stop")), + }, + }; + } + if (contentPresentationKind === "ready" && (snoozed || settled || woke)) { + const state = snoozed ? "snoozed" : settled ? "settled" : "woke"; + const label = + snoozed && snoozedUntil !== null + ? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}` + : settled + ? "Settled" + : "Woke from snooze"; + const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss"; + return { + kind: "lifecycle", + state, + label, + action: { + label: statusPending + ? snoozed + ? "Waking..." + : settled + ? "Un-settling..." + : "Dismissing..." + : actionLabel, + accessibilityLabel: actionLabel, + disabled: !props.canOperateThread || statusPending, + onPress: () => void runStatusAction(snoozed ? "wake" : settled ? "unsettle" : "dismiss"), + }, }; } if (props.selectedThread.goal !== null && contentPresentationKind === "ready") { @@ -1004,12 +1171,37 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai selectedThreadKey, ]); - const handleSendMessage = async (followUp?: ActiveTurnComposerAction) => { + const activePendingApprovalsRef = useRef(props.activePendingApprovals); + useLayoutEffect(() => { + activePendingApprovalsRef.current = props.activePendingApprovals; + }, [props.activePendingApprovals]); + const handleSendMessage = async ( + followUp?: ActiveTurnComposerAction, + stillSendable?: () => boolean, + ) => { const targetThreadKey = selectedThreadKey; const hasUserMessage = selectedThreadFeed.some( (entry) => entry.type === "message" && entry.message.role === "user", ); - const messageId = await props.onSendMessage(followUp); + // Runs only once the send path has validated the draft and is about to deliver it, so an + // empty submit, a queued-message edit, or `/feedback` leaves approvals alone. + const approvalsToDecline = props.activePendingApprovals; + const declineBeforeDispatch = + approvalsToDecline.length > 0 && props.activePendingUserInput === null + ? async () => + props.respondingApprovalId === null && + (await declinePendingApprovals(approvalsToDecline, props.onRespondToApproval)) && + selectedThreadKeyRef.current === targetThreadKey && + // A draft edited or pasted into during the decline isn't what the user sent. + stillSendable?.() !== false && + // An approval that arrived during the decline would block the redirect; the draft + // stays so the next send declines it too. + !activePendingApprovalsRef.current.some( + (approval) => + !approvalsToDecline.some((declined) => declined.requestId === approval.requestId), + ) + : undefined; + const messageId = await props.onSendMessage(followUp, declineBeforeDispatch); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } @@ -1458,7 +1650,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai canOperateThread={props.canOperateThread} reportedModelSelection={reportedModelSelection} editorRef={composerEditorRef} - placeholder="Ask the repo agent, or run a command…" + placeholder={ + props.activePendingApproval + ? "Send a message to decline and redirect the agent" + : "Ask the repo agent, or run a command…" + } contentMaxWidth={contentMaxWidth} connectionState={props.connectionStateLabel} environmentLabel={props.environmentLabel} @@ -1482,7 +1678,12 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai : null } draftKey={props.composerDraftKey ?? undefined} - followUpBehavior={props.followUpBehavior} + followUpBehavior={ + props.activePendingApprovals.length > 0 && + props.activePendingUserInput === null + ? "steer" + : props.followUpBehavior + } canSteerActiveTurn={props.canSteerActiveTurn} hidden={composerSlotHidden} queuedEdit={ diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ede7ded15774..e6063c2aacd1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,10 +1,11 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; +import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; +import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants"; import { - enrichedContextLinkPresentation, enrichedSkillDisplayName, enrichedSkillInstructionsPath, enrichedSkillReference, @@ -31,8 +32,11 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows"; +import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime"; import { + AuthFilesystemReadScope, type OrchestrationMessageContext, ThreadId, type ChatAttachment, @@ -62,7 +66,11 @@ import { import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; -import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + executeAtomQuery, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource, markdownImageSourceFragment, @@ -83,6 +91,7 @@ import { useDeviceType } from "../../native/native-layout-metrics"; import { isDuoDevice } from "../../lib/device-type"; import { memo, + useCallback, useContext, useEffect, useLayoutEffect, @@ -142,6 +151,8 @@ import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "./CopyTextButton"; +import { getReviewSectionIdForCheckpoint } from "../review/reviewModel"; +import { setReviewSelectedSectionId } from "../review/reviewState"; import { Menu } from "../../components/ui/Menu"; import { copyPromptLink } from "../prompt-links/prompt-link-actions"; import { parseReviewCommentMessageSegments } from "../review/reviewCommentSelection"; @@ -161,7 +172,6 @@ import { import { uuidv4 } from "../../lib/uuid"; import { resolveNativeMarkdownTypography } from "../../lib/appearancePreferences"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { markdownFileIconSource } from "../../lib/markdownFileIcons"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { @@ -206,7 +216,13 @@ import { ThreadMcpApp } from "./McpAppWebView"; import { assetEnvironment, useAssetUrl, useRefreshAssetUrl } from "../../state/assets"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { usePreparedConnection } from "../../state/session"; +import { readEnvironmentScope, usePreparedConnection } from "../../state/session"; +import { projectEnvironment } from "../../state/projects"; +import { + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { useLiveThreadLinkLabels, useProject, useQuietThreadShell } from "../../state/entities"; import { githubRepositoryUrl } from "@t3tools/shared/githubIssueReferences"; import { ThreadGitHubRepositoryContext, useReferenceLinks } from "../../state/referenceLinks"; @@ -220,7 +236,8 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useV2ItemSupport } from "../../state/v2-item-support"; import { fileRoutePathSegments, - isAbsolutePath, + resolveFileLinkPath, + resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; @@ -850,8 +867,6 @@ function useMarkdownStyles(): MarkdownStyleSets { const markdownUserCodeText = theme["--color-md-user-code-text"]; const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; - const inlineSkillForeground = theme["--color-inline-skill-foreground"]; - const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; const regularFontFamily = FONT_FAMILIES.regular; const boldFontFamily = FONT_FAMILIES.bold; @@ -868,7 +883,6 @@ function useMarkdownStyles(): MarkdownStyleSets { codeBackgroundColor: markdownUserCodeBg, codeBlockBackgroundColor: markdownUserFenceBg, fileTextColor: markdownUserBodyColor, - skillTextColor: userBubbleSkillForeground, quoteMarkerColor: markdownUserBodyColor, dividerColor: markdownUserBodyColor, contextChipBorderColor, @@ -891,7 +905,6 @@ function useMarkdownStyles(): MarkdownStyleSets { codeBackgroundColor: markdownCodeBg, codeBlockBackgroundColor: markdownCodeBg, fileTextColor: markdownCodeText, - skillTextColor: inlineSkillForeground, quoteMarkerColor: markdownBlockquoteBorder, dividerColor: markdownHrColor, contextChipBorderColor, @@ -960,6 +973,7 @@ function renderFeedEntry( readonly onToggleWorkGroup: (groupId: string, anchorKey?: string) => void; readonly onToggleWorkRow: (rowId: string, anchorKey?: string, groupId?: string) => void; readonly onToggleTurnFold: (runId: RunId) => void; + readonly onOpenTurnDiff: (checkpoint: ThreadCheckpointSummary) => void; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly markdownLinkHandlers: MarkdownLinkHandlers; @@ -986,29 +1000,38 @@ function renderFeedEntry( if (entry.type === "run-fold") { return ( - props.onToggleTurnFold(entry.runId)} - hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border-subtle px-2" - style={{ - minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), - }} - > - + props.onToggleTurnFold(entry.runId)} + hitSlop={4} + className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2" + style={{ + minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), + }} > - {entry.label} - - - + + {entry.label} + + + + {entry.checkpointSummary ? ( + + ) : null} + ); } @@ -1060,6 +1083,14 @@ function renderFeedEntry( hasFailure={entry.hasFailure} shimmer={entry.shimmer} thought={entry.thought} + trailing={ + entry.checkpointSummary ? ( + + ) : null + } onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -1413,6 +1444,12 @@ function renderFeedEntry( ); })} + {showAssistantMeta && message.checkpointSummary ? ( + + ) : null} {showAssistantMeta ? ( void; +}) { + const { files } = props.checkpoint; + const additions = files.reduce((total, file) => total + file.additions, 0); + const deletions = files.reduce((total, file) => total + file.deletions, 0); + const fileCountLabel = `${files.length} file${files.length === 1 ? "" : "s"} changed`; + return ( + props.onOpenTurnDiff(props.checkpoint)} + className="min-h-11 shrink-0 flex-row items-center gap-2 px-2 active:bg-subtle" + > + + +{formatDiffCount(additions)} + + + −{formatDiffCount(deletions)} + + + + ); +} + type UserMessageContentProps = { readonly text: string; readonly environmentId: EnvironmentId; @@ -1564,12 +1627,9 @@ function UserMessageContent(props: UserMessageContentProps) { }); } }, - linkCustomization: (href) => { - const presentation = enrichedContextLinkPresentation(href, props.context?.records); - return presentation - ? { color: presentation.color, icon: markdownFileIconSource(presentation.icon) } - : props.linkHandlers.linkCustomization?.(href); - }, + linkCustomization: (href) => + enrichedContextLinkCustomization(href, props.context?.records ?? []) ?? + props.linkHandlers.linkCustomization?.(href), }} /> {selected ? ( @@ -1699,6 +1759,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType); const openPullRequest = useOpenPullRequest(); const navigation = useNavigation(); + const onOpenTurnDiff = useCallback( + (checkpoint: ThreadCheckpointSummary) => { + setReviewSelectedSectionId( + scopedThreadKey(props.environmentId, props.threadId), + getReviewSectionIdForCheckpoint(checkpoint), + ); + navigation.navigate("ThreadReview", { + environmentId: props.environmentId, + threadId: props.threadId, + }); + }, + [navigation, props.environmentId, props.threadId], + ); const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); const disclosureSettleFrameRef = useRef(null); @@ -1722,9 +1795,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { previousTextSize.current = workRowSizing.textSizeKey; listRef.current?.clearCaches({ mode: "sizes" }); }, [workRowSizing.textSizeKey, listRef]); - const [viewportWidth, setViewportWidth] = useState(() => + const settledPaneWidth = useWorkspaceSettledContentWidth(); + const [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); + const viewportWidth = + settledPaneWidth !== null ? Math.round(settledPaneWidth) : measuredViewportWidth; const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); // LegendList's maintainScrollAtEnd alone re-pins the feed whenever the @@ -1761,6 +1837,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const markdownLinkRequest = useRef(null); + useFocusEffect(useCallback(() => () => markdownLinkRequest.current?.abort(), [])); + const markdownLinkScope = JSON.stringify([ + props.environmentId, + props.threadId, + props.contentPresentation.kind, + ]); + const previousMarkdownLinkScope = useRef(markdownLinkScope); + useLayoutEffect(() => { + if (previousMarkdownLinkScope.current === markdownLinkScope) return; + previousMarkdownLinkScope.current = markdownLinkScope; + markdownLinkRequest.current?.abort(); + }, [markdownLinkScope]); const fileShareSourceIdentifier = useId(); const shareFileChip = useFileChipShare( props.environmentId, @@ -1827,6 +1916,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); }; const onMarkdownLinkPress = (href: string, imageEmbed = false) => { + markdownLinkRequest.current?.abort(); + const request = new AbortController(); + markdownLinkRequest.current = request; const skill = enrichedSkillReference(href, props.skills); if (skill) { const instructionsPath = enrichedSkillInstructionsPath(skill); @@ -1849,41 +1941,111 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); if (media) { void Haptics.selectionAsync(); - if (media.kind === "video") { - setExpandedVideo((current) => current ?? media.source); - } else { - setExpandedFile((current) => current ?? media.source); - } + const openPreview = () => { + if (request.signal.aborted) return; + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); + } + }; + const resource = "resource" in media.source ? media.source.resource : null; + const mediaPath = resource && "path" in resource ? resource.path : null; + if ( + typeof mediaPath === "string" && + readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: mediaPath }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then((result) => { + if (request.signal.aborted) return; + if (result._tag === "Success" && result.value?.kind === "directory") { + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(mediaPath), + }); + } else openPreview(); + }, openPreview); + } else openPreview(); return; } const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { - const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, presentation.path); - if (relativePath) { + const path = resolveFileLinkPath(props.workspaceRoot, presentation.path); + if (path === null) return; + const relativePath = resolveWorkspaceRelativeFilePath(props.workspaceRoot, path); + const openAt = (target: string) => { + if (request.signal.aborted) return; void Haptics.selectionAsync(); navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: fileRoutePathSegments(relativePath), + path: fileRoutePathSegments(target), ...(presentation.line ? { line: String(presentation.line) } : {}), }); - return; - } - } - - if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { - void Haptics.selectionAsync(); - navigation.navigate("ThreadFile", { - environmentId: String(props.environmentId), - threadId: String(props.threadId), - path: fileRoutePathSegments(presentation.path), - ...(presentation.line ? { line: String(presentation.line) } : {}), - }); + }; + if ( + props.workspaceRoot && + relativePath && + !/[\\/]$/.test(presentation.path) && + needsWorkspaceBasenameLookup(relativePath) && + readEnvironmentScope(props.environmentId, AuthFilesystemReadScope) + ) { + const originalPath = resolveWorkspaceFilePath(props.workspaceRoot, relativePath); + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.searchEntries({ + environmentId: props.environmentId, + input: { + cwd: props.workspaceRoot, + query: relativePath, + kind: "file", + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then( + (result) => { + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(relativePath, result.value.entries) + : null; + if (!match || match === relativePath) { + openAt(relativePath); + return; + } + if (request.signal.aborted) return; + void executeAtomQuery( + appAtomRegistry, + projectEnvironment.fileMetadata({ + environmentId: props.environmentId, + input: { path: originalPath }, + }), + { signal: request.signal, reportFailure: false, reportDefect: false }, + ).then( + (metadata) => + openAt( + metadata._tag === "Success" && metadata.value?.kind !== "directory" + ? match + : relativePath, + ), + () => openAt(relativePath), + ); + }, + () => openAt(relativePath), + ); + } else openAt(relativePath ?? path); return; } - if (presentation.kind !== "file" && presentation.href) { + if (presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, @@ -1992,7 +2154,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { unavailable={false} alt={image.alt} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); } @@ -2010,7 +2172,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alt={image.alt} srcFragment={markdownImageSourceFragment(image.href)} actionsSource={media?.source.actionsSource} - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ); }; @@ -2039,7 +2201,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? { ...actionsSource, resource: viewedImage.resource } : undefined } - onPressPreview={(source) => setExpandedFile((current) => current ?? source)} + onPressPreview={onPressPreview} /> ) : null; }; @@ -2374,9 +2536,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }; const onPressPreview = (source: FilePreviewSource) => { + markdownLinkRequest.current?.abort(); setExpandedFile((current) => current ?? source); }; const onPressVideo = (attachment: ChatFileAttachment, sourceIdentifier: string) => { + markdownLinkRequest.current?.abort(); setExpandedVideo( (current) => current ?? attachmentVideoPreviewSource(props.environmentId, attachment, sourceIdentifier), @@ -2390,7 +2554,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (entry.type === "html-render") { return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); } - if (workRowSizing.fixedRowHeight === undefined) { + if (workRowSizing.fixedRowHeight === undefined || entry.checkpointSummary) { return undefined; } switch (entry.type) { @@ -2434,6 +2598,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (previousContentWidth.current === contentWidth) return; previousContentWidth.current = contentWidth; if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); + // A reflow can grow the visible rows past LegendList's near-end threshold in one batch, and + // maintainScrollAtEnd then stops following. Pin the end the feed was following once the new + // sizes are in. + if (!endFollowEnabledRef.current || !listRef.current?.getState().isAtEnd) return; + const frame = requestAnimationFrame(() => { + if (endFollowEnabledRef.current) void listRef.current?.scrollToEnd({ animated: false }); + }); + return () => cancelAnimationFrame(frame); }, [contentWidth, hasHtmlRenders, listRef]); const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => ( @@ -2465,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkGroup, onToggleWorkRow, onToggleTurnFold, + onOpenTurnDiff, onPressPreview, onPressVideo, markdownLinkHandlers, diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index 523a7a2afd95..8f93c772d23d 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -20,7 +20,8 @@ export function ThreadHeader( }, ) { const goBack = useWorkspaceBackNavigation("threads"); - const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } = + useAdaptiveWorkspaceLayout(); const { onOpenTerminal, onMergeBack } = props.gitControls; const native = useThreadHeaderOptions(props); const androidHeaderActions = (() => { @@ -88,6 +89,7 @@ export function ThreadHeader( onBack={layout.usesSplitView ? undefined : goBack} actions={androidHeaderActions} hideBottomBorder + compact={composerKeyboardActive} /> {native.fallback} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 67b7ce0fdbae..a8e958fcabd8 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { ThreadUndoNotice } from "../home/ThreadUndoNotice"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -754,6 +755,7 @@ function ThreadNavigationSidebarPane( snoozed={item.item.snoozed} pinned={item.item.pinned} hidden={item.item.hidden} + wokeAt={item.item.wokeAt} snoozePresetMinute={item.snoozePresetMinute ?? ""} snoozeWakeLabelText={item.snoozeWakeLabelText} timeLabel={item.timeLabel} @@ -982,6 +984,7 @@ function ThreadNavigationSidebarPane( /> + ); @@ -1116,6 +1119,7 @@ function ThreadNavigationSidebarPane( )} + ); } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ac9aa7c16c0..1418ed772366 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1144,6 +1144,7 @@ function ThreadRouteContent( : null } activePendingApproval={requests.activePendingApproval} + activePendingApprovals={requests.activePendingApprovals} respondingApprovalId={requests.respondingApprovalId} activePendingUserInput={requests.activePendingUserInput} activePendingUserInputDrafts={requests.activePendingUserInputDrafts} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 5854ca236d19..eab14dc38ed0 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,7 @@ +import { + getPromptControlledTraits, + getTraitSelectChange, +} from "@t3tools/client-runtime/model-traits"; import { SheetHeader } from "../../components/ui/SheetHeader"; import { Toolbar } from "../../components/ui/Toolbar"; import * as Dialog from "../../components/ui/Dialog"; @@ -247,6 +251,8 @@ type ThreadSettingsSessionProps = { readonly lockedProviderInstanceId?: ProviderInstanceId; readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; + readonly prompt: string; + readonly onPromptChange: (prompt: string) => void; readonly reportedModelSelection?: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; readonly restoreSelection?: (selection: ModelSelection) => ModelSelection; @@ -311,6 +317,7 @@ type ThreadSettingsSessionValue = { readonly runtimeModeChoices: ReturnType; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; + readonly promptTraits: ReturnType; readonly displayedModelSelection: ModelSelection | null; readonly reportedModelSelection: ModelSelection | null; readonly providerExpansionOverrides: ReadonlySet; @@ -491,6 +498,8 @@ function ThreadSettingsSessionProvider( () => new Set(), ); const [pendingModel, setPendingModel] = useState(null); + const [pendingPrompt, setPendingPrompt] = useState(null); + const displayedPrompt = pendingModel ? (pendingPrompt ?? props.prompt) : props.prompt; const isApplied = (option: ModelOption) => option.selection.instanceId === props.selectedModel?.instanceId && @@ -506,6 +515,7 @@ function ThreadSettingsSessionProvider( }) : [] : props.optionDescriptors; + const promptTraits = getPromptControlledTraits(displayedDescriptors, displayedPrompt); const displayedModel = pendingModel ?? props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ?? @@ -532,12 +542,39 @@ function ThreadSettingsSessionProvider( } void Haptics.selectionAsync(); props.onSelectModel(pendingModel); + if (pendingPrompt !== null && pendingPrompt !== props.prompt) { + props.onPromptChange(pendingPrompt); + } } return true; }; const applyOptionChange = (id: string, value: string | boolean) => { - const next = applyProviderOptionSelection(displayedDescriptors, { id, value }); + const descriptor = displayedDescriptors.find((entry) => entry.id === id); + const change = + descriptor?.type === "select" && typeof value === "string" + ? getTraitSelectChange( + { ...promptTraits, descriptors: displayedDescriptors }, + descriptor, + displayedPrompt, + value, + ) + : null; + if (descriptor?.type === "select") { + if (!change) return; + if (change.prompt !== displayedPrompt) { + if (pendingModel) { + setPendingPrompt(change.prompt); + } else { + props.onPromptChange(change.prompt); + } + } + if (!("modelOptions" in change)) return; + } + const next = + change && "modelOptions" in change + ? (change.modelOptions ?? []) + : applyProviderOptionSelection(displayedDescriptors, { id, value }); if (!next) { return; } @@ -564,6 +601,9 @@ function ThreadSettingsSessionProvider( const pressModel = (option: ModelOption) => { void Haptics.selectionAsync(); + if (isApplied(option) || option.key !== pendingModel?.key) { + setPendingPrompt(null); + } setPendingModel((current) => pendingModelAfterPress({ current, @@ -586,6 +626,7 @@ function ThreadSettingsSessionProvider( runtimeModeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, + promptTraits, displayedModelSelection: pendingModel?.selection ?? props.selectedModel, reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), favoriteKeys, @@ -832,11 +873,16 @@ function ThreadSettingsOptionsItem(props: { > props.onOpenSubmenu({ kind: "descriptor", id: descriptor.id })} /> @@ -1076,16 +1122,29 @@ function ThreadSettingsChoiceContent(props: { rows: selectableChoices(activeDescriptor).map((choice) => ({ id: choice.id, label: choice.label, - description: undefined, + description: choice.description, selected: choice.id === - getProviderOptionCurrentValue( - activeDescriptor, - session.displayedModelSelection, - session.reportedModelSelection, - ), + (session.promptTraits.ultrathinkPromptControlled && + activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id + ? "ultrathink" + : getProviderOptionCurrentValue( + activeDescriptor, + session.displayedModelSelection, + session.reportedModelSelection, + )), onPress: () => { void Haptics.selectionAsync(); + if ( + session.promptTraits.ultrathinkInBodyText && + activeDescriptor.id === session.promptTraits.primarySelectDescriptor?.id + ) { + Dialog.alert( + "Reasoning controlled by prompt", + 'Your prompt contains "ultrathink" in the text. Remove it to change this option.', + ); + return; + } session.applyOptionChange(activeDescriptor.id, choice.id); props.onSelected(); }, @@ -1482,6 +1541,8 @@ export function NewTaskThreadSettingsRouteScreen() { {...(flow.selectedModel ? { providerInstanceId: flow.selectedModel.instanceId } : {})} providerGroups={flow.providerGroups} selectedModel={flow.selectedModel} + prompt={flow.prompt} + onPromptChange={flow.setPrompt} onSelectModel={(option) => flow.setSelectedModelKey(option.key, option.selection.options)} restoreSelection={withRememberedModelOptions} optionDescriptors={optionDescriptors} diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index b744fb1be7dd..bee63a9775a0 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -187,22 +187,6 @@ describe("new-task branch checkout", () => { ); }); - it("fails when the source project is unavailable instead of releasing a composer selection", async () => { - const result = await checkoutNewTaskBranch({ - branch, - project: null, - workspaceMode: "local", - readRefs, - switchRef: () => { - throw new Error("An unavailable project must not run checkout"); - }, - }); - expect(result._tag).toBe("Failure"); - if (result._tag !== "Failure") throw new Error("Expected an unavailable-project failure"); - expect(String(squashAtomCommandFailure(result))).toContain("selected project is unavailable"); - expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); - }); - it("reuses an existing worktree without switching the project checkout", async () => { const worktreePath = NodePath.join(directory, "worktree"); await git("worktree", "add", worktreePath, "feature/a"); @@ -342,8 +326,6 @@ describe("new-task branch checkout", () => { it.each([ { source: "historical", nested: true }, - { source: "picker", nested: true }, - { source: "historical", nested: false }, { source: "picker", nested: false }, ])( "rejects $source worktree reuse when project root is unsafe (nested: $nested)", diff --git a/apps/mobile/src/features/threads/composerSendPresentation.test.ts b/apps/mobile/src/features/threads/composerSendPresentation.test.ts index 0d85e19a27b2..f8d01adcd4ee 100644 --- a/apps/mobile/src/features/threads/composerSendPresentation.test.ts +++ b/apps/mobile/src/features/threads/composerSendPresentation.test.ts @@ -11,44 +11,6 @@ const idle = { } as const; describe("resolveComposerSendPresentation", () => { - it("sends plainly while the thread is idle", () => { - const presentation = resolveComposerSendPresentation(idle); - - expect(presentation.label).toBe("Send"); - expect(presentation.icon).toBe("arrow.up"); - expect(presentation.action).toBeNull(); - }); - - it("says Queue while the outbox is holding the message back", () => { - expect(resolveComposerSendPresentation({ ...idle, deliveryDeferred: true }).label).toBe( - "Queue", - ); - }); - - it("follows the configured behavior once a turn is running", () => { - const queueing = resolveComposerSendPresentation({ - ...idle, - running: true, - canSteer: true, - followUpBehavior: "queue", - }); - const steering = resolveComposerSendPresentation({ - ...idle, - running: true, - canSteer: true, - followUpBehavior: "steer", - }); - - expect(queueing.label).toBe("Queue"); - expect(queueing.icon).toBe("list.number"); - expect(queueing.action).toBe("queue"); - expect(queueing.alternate).toBe("steer"); - expect(steering.label).toBe("Steer"); - expect(steering.icon).toBe("arrow.turn.left.up"); - expect(steering.action).toBe("steer"); - expect(steering.alternate).toBe("queue"); - }); - it("never promises steering the provider cannot do", () => { const presentation = resolveComposerSendPresentation({ ...idle, @@ -61,17 +23,4 @@ describe("resolveComposerSendPresentation", () => { expect(presentation.action).toBe("queue"); expect(presentation.alternate).toBeNull(); }); - - it("keeps the save affordance while a queued message is being edited", () => { - const presentation = resolveComposerSendPresentation({ - ...idle, - editingQueuedMessage: true, - running: true, - canSteer: true, - followUpBehavior: "steer", - }); - - expect(presentation.label).toBe("Update queued message"); - expect(presentation.icon).toBe("checkmark"); - }); }); diff --git a/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts b/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts deleted file mode 100644 index 5ac64626a97e..000000000000 --- a/apps/mobile/src/features/threads/composerSlashSkillSearch.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; - -const browserSkill = { - name: "browser", - path: "/skills/browser/SKILL.md", - enabled: true, - shortDescription: "Open and control the in-app browser", -}; - -describe("matchesSlashSkillQuery", () => { - it("matches the rendered skill prefix", () => { - expect(matchesSlashSkillQuery(browserSkill, "skill")).toBe(true); - expect(matchesSlashSkillQuery(browserSkill, "skill:brow")).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/threads/customSnoozeDate.test.ts b/apps/mobile/src/features/threads/customSnoozeDate.test.ts index 4486d2b7c7b5..18420e78dded 100644 --- a/apps/mobile/src/features/threads/customSnoozeDate.test.ts +++ b/apps/mobile/src/features/threads/customSnoozeDate.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - applySnoozePickerDate, - applySnoozePickerTime, - snoozeDateToPickerDate, -} from "./customSnoozeDate"; +import { applySnoozePickerDate, snoozeDateToPickerDate } from "./customSnoozeDate"; describe("custom snooze calendar conversion", () => { it("passes the local calendar day to Compose without shifting it across time zones", () => { @@ -24,13 +20,4 @@ describe("custom snooze calendar conversion", () => { ]).toEqual([2026, 8, 20, 23, 45]); expect(date.getDate()).toBe(16); }); - - it("applies a picked local time without changing the chosen calendar day", () => { - const date = new Date(2026, 8, 20, 23, 45); - const next = applySnoozePickerTime(date, new Date(2026, 8, 16, 8, 15, 30)); - expect([next.getDate(), next.getHours(), next.getMinutes(), next.getSeconds()]).toEqual([ - 20, 8, 15, 0, - ]); - expect(date.getHours()).toBe(23); - }); }); diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 999c403964b3..35757b8ae919 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -27,14 +27,17 @@ describe("resolveFileChipTarget", () => { }); }); - it("keeps only the full path for a host file outside the workspace", () => { - expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({ - fullPath: "/tmp/report.md", + it("resolves home links for navigation and menu actions", () => { + expect(resolveFileChipTarget("~/.agents/AGENTS.md:314", "/home/maria/repo")).toEqual({ + fullPath: "/home/maria/.agents/AGENTS.md", + }); + expect(resolveFileChipTarget("~/repo/src/main.ts:12", "/home/maria/repo")).toEqual({ + fullPath: "/home/maria/repo/src/main.ts", + relativePath: "src/main.ts", + }); + expect(resolveFileChipTarget("~/.agents/AGENTS.md", "C:\\Users\\maria\\repo")).toEqual({ + fullPath: "C:\\Users\\maria\\.agents\\AGENTS.md", }); - }); - - it("keeps only the relative path when the workspace root is unknown", () => { - expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" }); }); it("ignores links that are not files or cannot be opened", () => { @@ -44,22 +47,6 @@ describe("resolveFileChipTarget", () => { }); }); -describe("fileChipMenu", () => { - it("offers only the copies the target can satisfy", () => { - expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({ - title: "/tmp/report.md", - actions: [ - { id: "copy-full-path", title: "Copy full path" }, - { id: "open-file", title: "Open in file viewer" }, - ], - }); - expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([ - "copy-relative-path", - "open-file", - ]); - }); -}); - describe("file chip downloads", () => { const threadId = ThreadId.make("thread-1"); @@ -85,14 +72,6 @@ describe("file chip downloads", () => { }); }); - it("retains the thread context for a relative file without a known workspace root", () => { - expect( - fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId), - ).toMatchObject({ - resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" }, - }); - }); - it("does not offer downloads the host asset endpoint cannot serve", () => { for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { const target = resolveFileChipTarget(href, "/repo")!; diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index d020e5f542a3..8ab0d31c4b4b 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -7,6 +7,7 @@ import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, + resolveFileLinkPath, resolveWorkspaceFilePath, resolveWorkspaceRelativeFilePath, } from "../files/filePath"; @@ -30,9 +31,11 @@ export function resolveFileChipTarget( ): FileChipTarget | null { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind !== "file") return null; - const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path); - const fullPath = isAbsolutePath(presentation.path) - ? presentation.path + const path = resolveFileLinkPath(workspaceRoot, presentation.path); + if (path === null) return null; + const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, path); + const fullPath = isAbsolutePath(path) + ? path : workspaceRoot && relativePath ? resolveWorkspaceFilePath(workspaceRoot, relativePath) : undefined; diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 0a75084480c1..5024335899d9 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -84,6 +84,11 @@ export function FloatingWorkingControl(props: { const { width: windowWidth } = useWindowDimensions(); const [overlayWidth, setOverlayWidth] = useState(windowWidth); const [queueWidth, setQueueWidth] = useState(0); + const [actionWidth, setActionWidth] = useState(0); + const action = + props.status?.kind === "background" || props.status?.kind === "lifecycle" + ? props.status.action + : null; const [agentsWidth, setAgentsWidth] = useState(0); const hasQueue = props.queuedCount > 0; const hasPreview = props.devicePreview !== null || props.browserPreview !== null; @@ -95,6 +100,7 @@ export function FloatingWorkingControl(props: { Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 32 - + (action !== null ? actionWidth : 0) - (hasQueue ? queueWidth : 0) - (hasAgents ? agentsWidth : 0) - (hasPreview ? previewWidth : 0), @@ -159,8 +165,9 @@ export function FloatingWorkingControl(props: { const statusInteractive = props.status?.kind === "connection" || props.status?.kind === "child-input" || - (props.status?.kind === "background" && props.status.onPress !== undefined); - const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasPreview; + props.status?.kind === "background"; + const capsuleInteractive = + statusInteractive || action !== null || hasQueue || hasAgents || hasPreview; const statusContent = props.status !== null ? ( {statusContent} + {action !== null ? ( + setActionWidth(settleWidth(event.nativeEvent.layout.width))} + className={`h-11 flex-row items-center gap-1.5 px-3 active:opacity-70 ${action.disabled ? "opacity-50" : ""}`} + > + + + {action.label} + + + ) : null} {hasPreview ? ( ); } + if (props.status.kind === "lifecycle") { + return ( + + + + {props.status.label} + + + ); + } if (props.status.kind === "background") { return ( [0]["connectionState"], - overrides: { connectionError?: string | null; environmentLabel?: string | null } = {}, -) => - connectionFloatingStatus({ - connectionError: overrides.connectionError ?? null, - connectionState, - environmentLabel: - overrides.environmentLabel === undefined ? "Mac mini" : overrides.environmentLabel, - onReconnect: () => {}, - }); - -describe("connectionFloatingStatus", () => { - it("yields the pill to sync and working state once connected", () => { - expect(status("connected")).toBeNull(); - }); - - it("names the environment it is retrying, and says so only after a failure", () => { - expect(status("connecting")).toMatchObject({ - tone: "reconnecting", - label: "Reconnecting to Mac mini...", - }); - expect(status("reconnecting", { connectionError: "ECONNREFUSED" })).toMatchObject({ - tone: "reconnecting", - label: "Failed to connect. Retrying Mac mini...", - }); - }); - - it("reports why the environment is unreachable", () => { - expect(status("offline")).toMatchObject({ - tone: "unavailable", - label: "You are offline", - }); - expect(status("available")).toMatchObject({ - tone: "unavailable", - label: "Mac mini is not connected", - }); - expect(status("error", { connectionError: "handshake timed out" })).toMatchObject({ - label: "Failed to connect to Mac mini: handshake timed out", - }); - expect(status("error")).toMatchObject({ label: "Failed to connect to Mac mini" }); - }); - - it("falls back to a generic name when the environment has no label", () => { - expect(status("error", { environmentLabel: null })).toMatchObject({ - label: "Failed to connect to Environment", - }); - }); - - it("carries the reconnect handler so the pill can trigger it", () => { - const onReconnect = vi.fn(); - const pill = connectionFloatingStatus({ - connectionError: null, - connectionState: "offline", - environmentLabel: "Mac mini", - onReconnect, - }); - if (pill?.kind !== "connection") throw new Error("expected a connection pill"); - pill.onPress(); - expect(onReconnect).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 3e56be9b3119..51d4aefe4ec2 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -1,5 +1,12 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +export interface FloatingStatusAction { + readonly label: string; + readonly accessibilityLabel: string; + readonly disabled: boolean; + readonly onPress: () => void; +} + export type FloatingWorkingStatus = | { readonly kind: "working"; readonly startedAt: string } | { readonly kind: "syncing"; readonly label: string } @@ -16,7 +23,14 @@ export type FloatingWorkingStatus = readonly label: string; readonly accessibilityLabel: string; readonly waiting: boolean; - readonly onPress?: () => void; + readonly onPress: () => void; + readonly action: FloatingStatusAction; + } + | { + readonly kind: "lifecycle"; + readonly state: "snoozed" | "settled" | "woke"; + readonly label: string; + readonly action: FloatingStatusAction; } | { readonly kind: "goal"; readonly label: string; readonly accessibilityLabel: string } | { readonly kind: "preparing"; readonly label: string } diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx deleted file mode 100644 index 6b4e01a64fe6..000000000000 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { isValidElement, type ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - fields: [] as string[], - fieldIndex: 0, - canChangeThreadBranch: true, - navigations: 0, - result: Promise.resolve(null) as Promise, -})); - -vi.mock("react", async (importOriginal) => ({ - ...(await importOriginal()), - useState: (initial: string) => { - const index = state.fieldIndex++; - state.fields[index] ??= initial; - return [state.fields[index], (value: string) => (state.fields[index] = value)]; - }, -})); -vi.mock("react-native", () => ({ - Platform: { OS: "ios" }, - useWindowDimensions: () => ({ height: 800, width: 400 }), - Pressable: "Pressable", - ScrollView: "ScrollView", - View: "View", -})); -vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ goBack: () => state.navigations++ }), -})); -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ bottom: 0 }), -})); -vi.mock("../../../components/ui/AndroidScreenHeader", () => ({ - AndroidSheetHeader: "AndroidSheetHeader", -})); -vi.mock("../../../components/ui/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); -vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" })); -vi.mock("../../../state/query", () => ({ - useEnvironmentQuery: () => ({ data: { refName: "main" } }), -})); -vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); -vi.mock("../../../state/use-thread-selection", () => ({ - useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }), -})); -vi.mock("../../../state/use-selected-thread-worktree", () => ({ - useSelectedThreadWorktree: () => ({ - selectedThreadCwd: "/repo", - selectedThreadWorktreePath: null, - }), -})); -vi.mock("../../../state/use-selected-thread-git-state", () => ({ - useSelectedThreadGitState: () => ({ - selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - selectedThreadBranchesLoading: false, - gitOperationLabel: null, - }), -})); -vi.mock("../../../state/use-selected-thread-git-actions", () => ({ - useSelectedThreadGitActions: () => ({ - canChangeThreadBranch: state.canChangeThreadBranch, - onCreateSelectedThreadBranch: () => state.result, - onCreateSelectedThreadWorktree: () => state.result, - onCheckoutSelectedThreadBranch: () => state.result, - }), -})); - -import { GitBranchesSheet } from "./GitBranchesSheet"; - -type ControlProps = { - label?: string; - placeholder?: string; - onPress?: () => unknown; - onChangeText?: (value: string) => void; - value?: string; - children?: ReactNode; -}; - -function findControl(node: ReactNode, label: string): ControlProps | null { - if (Array.isArray(node)) { - for (const child of node) { - const control = findControl(child, label); - if (control) return control; - } - return null; - } - if (!isValidElement(node)) return null; - if ( - node.props.label === label || - node.props.placeholder === label || - (label === "checkout" && node.type === "Pressable") - ) { - return node.props; - } - return findControl(node.props.children, label); -} - -function control(label: string) { - state.fieldIndex = 0; - const found = findControl( - GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }), - label, - ); - if (!found) throw new Error(`Missing sheet control: ${label}`); - return found; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - return { promise, resolve }; -} - -describe("branch sheet operation completion", () => { - beforeEach(() => { - state.fields = []; - state.fieldIndex = 0; - state.canChangeThreadBranch = true; - state.navigations = 0; - control("feature/mobile-polish").onChangeText?.("feature/branch"); - control("main").onChangeText?.("release"); - control("feature/mobile-thread").onChangeText?.("feature/worktree"); - }); - - it.each(["Create & checkout", "Create worktree", "checkout"])( - "keeps the sheet and its input when %s cannot complete", - async (operation) => { - const result = deferred(); - state.result = result.promise; - const press = control(operation).onPress; - if (!press) throw new Error("Missing operation handler"); - state.canChangeThreadBranch = false; - const completion = press(); - result.resolve(null); - await result.promise; - await completion; - - expect(state.navigations).toBe(0); - expect(control("feature/mobile-polish").value).toBe("feature/branch"); - expect(control("main").value).toBe("release"); - expect(control("feature/mobile-thread").value).toBe("feature/worktree"); - }, - ); - - it.each(["Create & checkout", "Create worktree", "checkout"])( - "dismisses after accepted %s completion even if the source grant changes", - async (operation) => { - const result = deferred(); - state.result = result.promise; - const press = control(operation).onPress; - if (!press) throw new Error("Missing operation handler"); - const completion = press(); - state.canChangeThreadBranch = false; - result.resolve( - operation === "Create worktree" - ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } } - : { refName: operation === "checkout" ? "main" : "feature/branch" }, - ); - await result.promise; - await completion; - - expect(state.navigations).toBe(1); - expect(control("feature/mobile-polish").value).toBe( - operation === "Create & checkout" ? "" : "feature/branch", - ); - expect(control("feature/mobile-thread").value).toBe( - operation === "Create worktree" ? "" : "feature/worktree", - ); - }, - ); -}); diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7bc5105805b5..be0912a6ebea 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -78,7 +78,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { /> ) : null} navigation.goBack()} @@ -86,6 +86,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { /> { - it("replaces the sheet so Back returns directly to the thread", () => { - expect(resolveGitOverviewReviewNavigationAction("sheet")).toBe("replace"); - }); - - it("pushes Review normally when Git is a persistent inspector", () => { - expect(resolveGitOverviewReviewNavigationAction("inspector")).toBe("navigate"); - }); -}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts deleted file mode 100644 index 614640cc3f8a..000000000000 --- a/apps/mobile/src/features/threads/markdownImageSize.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - MARKDOWN_IMAGE_MAX_HEIGHT, - MARKDOWN_IMAGE_MAX_WIDTH, - resolveMarkdownImageDisplaySize, - resolveSvgImageSize, -} from "./markdownImageSize"; - -describe("resolveMarkdownImageDisplaySize", () => { - it("keeps small images at their intrinsic size", () => { - expect( - resolveMarkdownImageDisplaySize({ - sourceWidth: 96, - sourceHeight: 96, - availableWidth: 332, - }), - ).toEqual({ width: 96, height: 96 }); - }); - - it("fits wide images to the available chat width", () => { - expect( - resolveMarkdownImageDisplaySize({ - sourceWidth: 960, - sourceHeight: 540, - availableWidth: 332, - }), - ).toEqual({ width: 332, height: 186.75 }); - }); - - it("caps wide images at 480 points on larger screens", () => { - expect( - resolveMarkdownImageDisplaySize({ - sourceWidth: 960, - sourceHeight: 540, - availableWidth: 900, - }), - ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 }); - }); - - it("caps tall images by height without changing their aspect ratio", () => { - expect( - resolveMarkdownImageDisplaySize({ - sourceWidth: 400, - sourceHeight: 800, - availableWidth: 332, - }), - ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT }); - }); - - it("rejects dimensions that cannot produce a stable layout", () => { - expect( - resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }), - ).toBeNull(); - expect( - resolveMarkdownImageDisplaySize({ - sourceWidth: 100, - sourceHeight: Number.NaN, - availableWidth: 332, - }), - ).toBeNull(); - }); -}); - -describe("resolveSvgImageSize", () => { - it("uses pixel dimensions before the viewBox", () => { - expect(resolveSvgImageSize({ width: "32px", height: "48", viewBox: "0 0 100 100" })).toEqual({ - width: 32, - height: 48, - }); - }); - - it("uses the viewBox for responsive SVGs", () => { - expect( - resolveSvgImageSize({ width: "100%", height: "100%", viewBox: "-10, -20, 120, 240" }), - ).toEqual({ width: 120, height: 240 }); - expect(resolveSvgImageSize({ viewBox: "0 0 100 NaN" })).toBeNull(); - expect(resolveSvgImageSize({ viewBox: "0 0 0 100" })).toBeNull(); - }); -}); diff --git a/apps/mobile/src/features/threads/mcpAppSizing.test.ts b/apps/mobile/src/features/threads/mcpAppSizing.test.ts deleted file mode 100644 index d6e9bd6e0d53..000000000000 --- a/apps/mobile/src/features/threads/mcpAppSizing.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { mcpAppInlineHeight } from "./mcpAppSizing"; - -describe("inline MCP app height", () => { - it.each([undefined, NaN, Infinity, -Infinity])( - "ignores invalid or absent height %s", - (height) => { - expect(mcpAppInlineHeight(height)).toBeUndefined(); - }, - ); - - it.each([ - [-10, 80], - [0, 80], - [149.6, 150], - [420, 420], - [2000, 420], - ])("sizes content height %s within the inline viewport", (height, expected) => { - expect(mcpAppInlineHeight(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index d79cc596a7be..64360ff905e7 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,12 +1,51 @@ import { describe, expect, it } from "vite-plus/test"; import { - filterNewTaskBranches, resolveNewTaskBranchWorktreePath, - resolveNewTaskBranchLabel, resolveNewTaskLocalWorkspaceSelection, + resolveNewTaskWorkspaceSelection, } from "./new-task-context-presentation"; +describe("resolveNewTaskWorkspaceSelection", () => { + const fallback = { mode: "worktree", branch: null, worktreePath: null } as const; + + it("falls back to the current checkout when a non-git project defaults to worktree", () => { + expect( + resolveNewTaskWorkspaceSelection({ selection: undefined, fallback, isGitRepo: false }), + ).toEqual({ mode: "local", branch: null, worktreePath: null }); + }); + + it.each(["local", "worktree"] as const)( + "clears stale %s selections for non-git projects", + (mode) => { + expect( + resolveNewTaskWorkspaceSelection({ + selection: { + mode, + branch: "main", + worktreePath: "/old-worktree", + startFromOrigin: true, + pullRequestReference: "https://github.com/acme/repo/pull/123", + }, + fallback, + isGitRepo: false, + }), + ).toEqual({ mode: "local", branch: null, worktreePath: null }); + }, + ); + + // regression: #21404 use current checkout for non-git projects + it("preserves the selection and defaults for git projects", () => { + const selection = { mode: "local", branch: "feature", worktreePath: "/worktree" } as const; + expect(resolveNewTaskWorkspaceSelection({ selection, fallback, isGitRepo: true })).toBe( + selection, + ); + expect( + resolveNewTaskWorkspaceSelection({ selection: undefined, fallback, isGitRepo: true }), + ).toBe(fallback); + }); +}); + describe("resolveNewTaskLocalWorkspaceSelection", () => { it("waits for refs instead of carrying a worktree base into Current checkout", () => { expect( @@ -21,22 +60,6 @@ describe("resolveNewTaskLocalWorkspaceSelection", () => { }); }); - it("adopts the checkout's current branch once refs load", () => { - expect( - resolveNewTaskLocalWorkspaceSelection({ - branches: [ - { name: "feature/worktree-base", current: false, worktreePath: "/worktree" }, - { name: "main", current: true, worktreePath: "/repo" }, - ], - projectCwd: "/repo", - }), - ).toEqual({ - branch: "main", - worktreePath: null, - awaitsCurrentBranch: false, - }); - }); - it("carries the worktree path when the current branch lives in another worktree", () => { expect( resolveNewTaskLocalWorkspaceSelection({ @@ -55,26 +78,6 @@ describe("resolveNewTaskLocalWorkspaceSelection", () => { }); describe("resolveNewTaskBranchWorktreePath", () => { - it("moves Current checkout to the selected existing worktree", () => { - expect( - resolveNewTaskBranchWorktreePath({ - workspaceMode: "local", - projectCwd: "/repo", - branchWorktreePath: "/repo/.t3/worktrees/feature", - }), - ).toBe("/repo/.t3/worktrees/feature"); - }); - - it("keeps the project checkout represented by a null override", () => { - expect( - resolveNewTaskBranchWorktreePath({ - workspaceMode: "local", - projectCwd: "/repo", - branchWorktreePath: "/repo", - }), - ).toBeNull(); - }); - it("does not reuse an existing worktree while creating a new one", () => { expect( resolveNewTaskBranchWorktreePath({ @@ -85,78 +88,3 @@ describe("resolveNewTaskBranchWorktreePath", () => { ).toBeNull(); }); }); - -describe("resolveNewTaskBranchLabel", () => { - it("identifies a deferred PR checkout independently of the origin default", () => { - expect( - resolveNewTaskBranchLabel({ - branchName: "fork/feature", - startFromOrigin: true, - workspaceMode: "worktree", - pullRequestReference: "https://github.com/acme/repo/pull/123", - }), - ).toBe("From PR #123"); - }); - it("shows the checked-out branch without a base-ref prefix", () => { - expect( - resolveNewTaskBranchLabel({ - branchName: "feature/mobile", - startFromOrigin: true, - workspaceMode: "local", - }), - ).toBe("feature/mobile"); - }); - - it("labels a local worktree base with From", () => { - expect( - resolveNewTaskBranchLabel({ - branchName: "main", - startFromOrigin: false, - workspaceMode: "worktree", - }), - ).toBe("From main"); - }); - - it("labels a remote worktree base with a remote marker", () => { - expect( - resolveNewTaskBranchLabel({ - branchName: "main", - startFromOrigin: true, - workspaceMode: "worktree", - }), - ).toBe("From main (remote)"); - }); - - it("prompts when no branch is available", () => { - expect( - resolveNewTaskBranchLabel({ - branchName: null, - startFromOrigin: true, - workspaceMode: "worktree", - }), - ).toBe("Choose branch"); - }); -}); - -describe("filterNewTaskBranches", () => { - const branches = [ - { name: "main", isRemote: false }, - { name: "Feature/Login-Page", isRemote: false }, - { name: "origin/fix/remote-only", isRemote: true }, - ]; - const search = (query: string) => - filterNewTaskBranches(branches, query).map((branch) => branch.name); - - it("ignores case in both the query and the branch name", () => { - expect(search("feature/login")).toEqual(["Feature/Login-Page"]); - expect(search("MAIN")).toEqual(["main"]); - }); - - it("keeps remote-only branches searchable", () => { - expect(search("remote-only")).toEqual(["origin/fix/remote-only"]); - }); - - it("matches a typed space against the dash a branch name uses", () => { - expect(search(" login page ")).toEqual(["Feature/Login-Page"]); - }); -}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index dd3b23686ce4..bd2add22eb64 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,8 +1,20 @@ import { sanitizeNewRefName } from "@t3tools/shared/git"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; +import type { ComposerDraftWorkspaceSelection } from "../../state/use-composer-drafts"; type WorkspaceMode = "local" | "worktree"; +export function resolveNewTaskWorkspaceSelection(input: { + readonly selection: ComposerDraftWorkspaceSelection | undefined; + readonly fallback: ComposerDraftWorkspaceSelection; + readonly isGitRepo: boolean; +}): ComposerDraftWorkspaceSelection { + const selection = input.selection ?? input.fallback; + const mode = resolveSendEnvMode({ requestedEnvMode: selection.mode, isGitRepo: input.isGitRepo }); + return input.isGitRepo ? selection : { mode, branch: null, worktreePath: null }; +} + export function resolveNewTaskWorkspaceLabel(input: { readonly workspaceMode: WorkspaceMode; readonly worktreePath: string | null; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 065cc73ce178..23618df9c8e6 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -79,7 +79,7 @@ import { rememberModelOptions, withRememberedModelOptions, } from "../../state/use-model-option-memory"; -import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; +import { useBranches, useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { flattenQueuedThreadMessages, @@ -119,6 +119,7 @@ import { filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, + resolveNewTaskWorkspaceSelection, } from "./new-task-context-presentation"; import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -169,6 +170,8 @@ type NewTaskFlowContextValue = { readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; readonly workspaceMode: WorkspaceMode; + readonly isGitRepo: boolean; + readonly isGitRepoKnown: boolean; readonly canChooseWorkspace: boolean; readonly selectedBranchName: string | null; readonly selectedWorktreePath: string | null; @@ -435,6 +438,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const projectGitStatus = useEnvironmentQuery( + selectedProject !== null && selectedProject.workspaceRoot !== "" + ? vcsEnvironment.status({ + environmentId: selectedProject.environmentId, + input: { cwd: selectedProject.workspaceRoot }, + }) + : null, + ); + const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; + // The unfiltered ref list also provides repository capability while offline. + const projectBranches = useBranches({ + environmentId: selectedProject?.environmentId ?? null, + cwd: selectedProject?.workspaceRoot || null, + }); + const knownIsGitRepo = projectGitStatus.data?.isRepo ?? projectBranches.data?.isRepo ?? null; + const isGitRepo = knownIsGitRepo ?? true; const fileAccessSession = useEnvironmentQuery( selectedProject !== null && selectedProject.workspaceRoot !== "" ? environmentSession.sessionStateAtom(selectedProject.environmentId) @@ -497,17 +516,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraft.workspaceSelection?.mode !== undefined || projectSettings.sources.defaultThreadEnvMode !== "environment" || (!t3ProjectFileQuery.isPending && !fileAccessPending); - const workspaceMode = canChooseWorkspace - ? (selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode) - : "local"; - const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; - const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const workspaceSelection = resolveNewTaskWorkspaceSelection({ + selection: canChooseWorkspace ? selectedProjectDraft.workspaceSelection : undefined, + fallback: { mode: defaultWorkspaceMode, branch: null, worktreePath: null }, + isGitRepo, + }); + const workspaceMode = workspaceSelection.mode; + const selectedBranchName = workspaceSelection.branch; + const selectedWorktreePath = workspaceSelection.worktreePath; // Keep the user's explicit choice separate from the resolved display value: // only the explicit flag is ever written back to the draft, so the resolved // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = - draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; + isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin); const defaultRuntimeMode = editingPendingTask ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) : projectSettings.settings.defaultRuntimeMode; @@ -662,15 +684,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { allBranchRefs, Arr.filter((branch) => !branch.isRemote), ); - const projectGitStatus = useEnvironmentQuery( - branchTarget.environmentId !== null && branchTarget.cwd !== null - ? vcsEnvironment.status({ - environmentId: branchTarget.environmentId, - input: { cwd: branchTarget.cwd }, - }) - : null, - ); - const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; const filteredBranches = filterNewTaskBranches(allBranchRefs, branchQuery); @@ -971,8 +984,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) { return null; } - const workspaceSelection = canChooseWorkspace ? draft.workspaceSelection : undefined; - const mode = workspaceSelection?.mode ?? workspaceMode; + const workspaceSelection = resolveNewTaskWorkspaceSelection({ + selection: canChooseWorkspace ? draft.workspaceSelection : undefined, + fallback: { mode: workspaceMode, branch: null, worktreePath: null }, + isGitRepo, + }); + const mode = workspaceSelection.mode; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -1131,6 +1148,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedModelKey, workspaceMode, + isGitRepo, + isGitRepoKnown: knownIsGitRepo !== null, canChooseWorkspace, selectedBranchName, selectedWorktreePath, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 1dfcc2d636bd..a5289259fe49 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,8 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { - filterProjectScopes, - getProjectScopeSelectionTarget, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; @@ -54,22 +52,6 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop }; } -describe("getProjectScopeSelectionTarget", () => { - it("keeps the current environment when it hosts the selected logical project", () => { - const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; - expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("server"))).toBe( - projects[1], - ); - }); - - it("falls back to the representative when the current environment does not host the project", () => { - const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; - expect(getProjectScopeSelectionTarget(makeScope(projects), EnvironmentId.make("other"))).toBe( - projects[0], - ); - }); -}); - describe("resolveEnvironmentProjectMatch", () => { it("follows the same repository onto the target machine", () => { const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); @@ -109,31 +91,9 @@ describe("resolveEnvironmentProjectMatch", () => { expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); }); - - it("falls back to the first project on the target so the draft has a key to carry over to", () => { - const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); - const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; - expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); - expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); - }); }); describe("resolveDraftProjectSelection", () => { - it("preserves an explicit project selection", () => { - const project = makeProject("t3code"); - expect( - resolveDraftProjectSelection("environment:t3code", [project], [makeScope([project])]), - ).toEqual({ kind: "preserve" }); - }); - - it("selects the only physical project when no project was explicitly selected", () => { - const project = makeProject("t3code"); - expect(resolveDraftProjectSelection(null, [project], [makeScope([project])])).toEqual({ - kind: "select", - project, - }); - }); - it("selects one logical project even when it has multiple physical workspaces", () => { const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ @@ -152,31 +112,3 @@ describe("resolveDraftProjectSelection", () => { }); }); }); - -describe("filterProjectScopes", () => { - const mac = makeProject("code", "mac", { title: "Desktop checkout" }); - const server = makeProject("remote-code", "server", { workspaceRoot: "/srv/remote-workspace" }); - const code = makeScope([mac, server]); - const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" }; - const scopes = [code, docs]; - - it("keeps all projects for an empty or whitespace-only query", () => { - expect(filterProjectScopes(scopes, "")).toBe(scopes); - expect(filterProjectScopes(scopes, " ")).toBe(scopes); - }); - - it("matches logical names and workspace names or paths without case sensitivity", () => { - expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]); - expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]); - expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]); - expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]); - expect(filterProjectScopes(scopes, "missing-project")).toEqual([]); - }); - - it("preserves the whole logical project and preferred environment when a workspace matches", () => { - const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE"); - expect(matches[0]).toBe(code); - expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(mac); - expect(code.projects).toEqual([mac, server]); - }); -}); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts deleted file mode 100644 index 8dd15ccc8d87..000000000000 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; - -describe("derivePendingUserInputMaxHeight", () => { - it("caps a tall portrait viewport", () => { - expect( - derivePendingUserInputMaxHeight({ - windowHeight: 932, - keyboardHeight: 0, - navigationHeaderHeight: 103, - composerOverlapHeight: 94, - }), - ).toBe(560); - }); - - it("subtracts the keyboard while editing a custom answer", () => { - expect( - derivePendingUserInputMaxHeight({ - windowHeight: 932, - keyboardHeight: 336, - navigationHeaderHeight: 103, - composerOverlapHeight: 94, - }), - ).toBe(387); - }); - - it("keeps the fixed action area usable in a short keyboard-open viewport", () => { - expect( - derivePendingUserInputMaxHeight({ - windowHeight: 375, - keyboardHeight: 240, - navigationHeaderHeight: 44, - composerOverlapHeight: 94, - }), - ).toBe(160); - }); -}); diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts index 5c4980e6e03d..f6d4327d191c 100644 --- a/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.test.ts @@ -13,26 +13,6 @@ describe("resolveProjectThreadCreationBranch", () => { ).toBe("feature/x"); }); - it("prefers an explicit picker choice over the current checkout", () => { - expect( - resolveProjectThreadCreationBranch({ - workspaceMode: "local", - selectedBranch: "main", - currentCheckoutBranch: "feature/x", - }), - ).toBe("main"); - }); - - it("stays null when no ref is checked out (detached HEAD, non-repository, status not loaded)", () => { - expect( - resolveProjectThreadCreationBranch({ - workspaceMode: "local", - selectedBranch: null, - currentCheckoutBranch: null, - }), - ).toBeNull(); - }); - it("never borrows the current checkout for a worktree draft", () => { expect( resolveProjectThreadCreationBranch({ @@ -42,14 +22,4 @@ describe("resolveProjectThreadCreationBranch", () => { }), ).toBeNull(); }); - - it("keeps the explicit base branch for a worktree draft", () => { - expect( - resolveProjectThreadCreationBranch({ - workspaceMode: "worktree", - selectedBranch: "main", - currentCheckoutBranch: "feature/x", - }), - ).toBe("main"); - }); }); diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts index c4162282124d..d0c673b868ca 100644 --- a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts +++ b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts @@ -1,33 +1,13 @@ import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; describe("subagent card", () => { - it("shows readable result text and suppresses generic completion messages", () => { - expect( - subagentCardDetail( - "- Updated `app.ts` with [the fix](https://example.com).\n- Checked tests.", - ), - ).toBe("Updated app.ts with the fix. Checked tests."); - expect(subagentCardDetail("Child task ended with status failed.")).toBeNull(); - expect(subagentCardDetail(" ")).toBeNull(); - }); - const start = DateTime.makeUnsafe("2026-09-21T12:00:00Z"); const end = DateTime.makeUnsafe("2026-09-21T12:01:00Z"); const later = DateTime.makeUnsafe("2026-09-21T12:02:00Z"); const done = { status: "completed" as const, startedAt: start, completedAt: end }; - it("freezes settled durations and spans the group's wall time", () => { - expect(subagentCardElapsed([done], DateTime.toEpochMillis(later))).toBe("1m"); - expect( - subagentCardElapsed( - [done, { ...done, startedAt: end, completedAt: later }], - DateTime.toEpochMillis(later), - ), - ).toBe("2m"); - }); - it("counts live work but never uses a settled agent's age as its duration", () => { const unfinished = { ...done, completedAt: null }; expect(subagentCardElapsed([unfinished], DateTime.toEpochMillis(later))).toBeNull(); diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts deleted file mode 100644 index 5612cfbf70d5..000000000000 --- a/apps/mobile/src/features/threads/thread-activity-row-presentation.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - resolveThreadActivityMetadata, - resolveThreadActivityStatus, -} from "./thread-activity-row-presentation"; - -describe("thread activity row presentation", () => { - it("shows the provider and model as compact metadata", () => { - expect( - resolveThreadActivityMetadata({ - providerDriver: ProviderDriverKind.make("codex"), - providerInstanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }), - ).toBe("Codex · gpt-5.4"); - }); - - it("falls back to the provider instance and removes duplicate metadata", () => { - expect( - resolveThreadActivityMetadata({ - providerDriver: null, - providerInstanceId: ProviderInstanceId.make("custom-agent"), - model: "custom-agent", - }), - ).toBe("custom-agent"); - }); - - it("maps lifecycle status to dot tone and an accessible label", () => { - expect(resolveThreadActivityStatus("idle")).toEqual({ label: "Idle", tone: "neutral" }); - expect(resolveThreadActivityStatus("running")).toEqual({ label: "Running", tone: "active" }); - expect(resolveThreadActivityStatus("completed")).toEqual({ - label: "Completed", - tone: "success", - }); - expect(resolveThreadActivityStatus("failed")).toEqual({ label: "Failed", tone: "danger" }); - expect(resolveThreadActivityStatus("interrupted")).toEqual({ - label: "Interrupted", - tone: "warning", - }); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-activity-row-presentation.ts b/apps/mobile/src/features/threads/thread-activity-row-presentation.ts deleted file mode 100644 index d5bde12ddb7f..000000000000 --- a/apps/mobile/src/features/threads/thread-activity-row-presentation.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - PROVIDER_DISPLAY_NAMES, - type OrchestrationV2TurnItem, - type ProviderDriverKind, - type ProviderInstanceId, -} from "@t3tools/contracts"; - -export function resolveThreadActivityMetadata(input: { - readonly providerDriver: ProviderDriverKind | null; - readonly providerInstanceId: ProviderInstanceId | null; - readonly model: string | null; -}): string { - const providerLabel = input.providerDriver - ? (PROVIDER_DISPLAY_NAMES[input.providerDriver] ?? input.providerInstanceId) - : input.providerInstanceId; - const values = [providerLabel, input.model].filter( - (value): value is string => value !== null && value.length > 0, - ); - return [...new Set(values)].join(" · "); -} - -export function resolveThreadActivityStatus(status: OrchestrationV2TurnItem["status"]): { - readonly label: string; - readonly tone: "active" | "danger" | "success" | "warning" | "neutral"; -} { - const label = status.charAt(0).toUpperCase() + status.slice(1).replaceAll("_", " "); - switch (status) { - case "idle": - return { label, tone: "neutral" }; - case "completed": - return { label, tone: "success" }; - case "failed": - return { label, tone: "danger" }; - case "cancelled": - case "interrupted": - return { label, tone: "warning" }; - case "pending": - case "running": - case "waiting": - return { label, tone: "active" }; - } -} diff --git a/apps/mobile/src/features/threads/thread-feed-item-size.test.ts b/apps/mobile/src/features/threads/thread-feed-item-size.test.ts deleted file mode 100644 index d2780fff584f..000000000000 --- a/apps/mobile/src/features/threads/thread-feed-item-size.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; - -describe("resolveThreadFeedFixedItemSize", () => { - it("leaves activity groups to native measurement", () => { - expect(resolveThreadFeedFixedItemSize("activity-group")).toBeUndefined(); - }); - - it("keeps fixed timeline chrome on the premeasured path", () => { - expect(resolveThreadFeedFixedItemSize("run-fold")).toBe(42); - expect(resolveThreadFeedFixedItemSize("work-toggle")).toBe(28); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 13e81130823a..b8c067ca826a 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -27,11 +27,6 @@ describe("tool-group scroll restoration", () => { viewOffset: -80, }); }); - - it("starts normally when the saved row no longer belongs to the group", () => { - expect(resolveThreadWorkGroupInitialScroll([{ id: "other" }], position)).toBeUndefined(); - expect(resolveThreadWorkGroupInitialScroll([{ id: "read-output" }], undefined)).toBeUndefined(); - }); }); describe("tool-group append following", () => { @@ -68,11 +63,7 @@ describe("tool-group append following", () => { it.each([ { name: "reading earlier calls", changes: { scrollOffset: 20 } }, - { name: "dragging before leaving the edge", changes: { userScrolling: true } }, - { name: "opening detail during an append", changes: { detailsChanged: true } }, { name: "streaming a result", changes: { rows: previousRows, contentHeight: 600 } }, - { name: "updating a lifecycle label", changes: { rows: previousRows, contentHeight: 289 } }, - { name: "prepending old calls", changes: { rows: [{ id: "older" }, ...previousRows] } }, { name: "replacing a call while appending", changes: { rows: [{ id: "replacement" }, ...appendedRows.slice(1)] }, @@ -107,18 +98,6 @@ describe("resolveThreadFeedSubmissionAnchor", () => { ).toBe("first-message"); }); - it("preserves the first-message anchor after its outbox entry drains", () => { - expect( - resolveThreadFeedSubmissionAnchor({ - currentAnchorMessageId: "first-message", - submittedMessageId: "second-message", - hasStartedTurn: false, - hasUserMessage: false, - queuedMessageCount: 0, - }), - ).toBe("first-message"); - }); - it("does not anchor a follow-up after a user message appears", () => { expect( resolveThreadFeedSubmissionAnchor({ @@ -131,6 +110,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { ).toBeNull(); }); + // regression: #7897 stop pushing follow-up messages to the top it("does not anchor a thread that has already started a turn", () => { expect( resolveThreadFeedSubmissionAnchor({ @@ -145,20 +125,11 @@ describe("resolveThreadFeedSubmissionAnchor", () => { }); describe("resolveThreadFeedLiveFollow", () => { + // regression: #5986 stabilize thread composer and interactions it("pauses immediately when the user starts scrolling", () => { expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); }); - it("stays paused away from the actual end", () => { - expect( - resolveThreadFeedLiveFollow(false, { - type: "scroll", - isAtEnd: false, - userScrollSessionActive: true, - }), - ).toBe(false); - }); - it("does not mistake programmatic layout compensation for a user scroll", () => { expect( resolveThreadFeedLiveFollow(true, { @@ -206,18 +177,4 @@ describe("resolveThreadFeedLiveFollow", () => { }), ).toBe(false); }); - - it("ignores momentum-end events from programmatic scrolling", () => { - expect( - resolveThreadFeedLiveFollow(true, { - type: "user-scroll-end", - isAtEnd: false, - userScrollSessionActive: false, - }), - ).toBe(true); - }); - - it("re-arms after an explicit reset", () => { - expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); - }); }); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2898a403d22f..0690600b7733 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -12,6 +12,9 @@ import { RowPressable } from "../../components/ui/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useVisibleSecondClock } from "./use-visible-second-clock"; import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, @@ -23,7 +26,11 @@ import { type EnvironmentMachineKind, ProjectId, } from "@t3tools/contracts"; -import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; +import { + formatWorkingDurationLabel, + resolveThreadWorkingStartedAt, + threadRuntimeCanArchive, +} from "@t3tools/client-runtime/state/models"; import { threadMoveTargets } from "@t3tools/client-runtime/state/projects"; import { resolveThreadSubscriptionLimitGuard } from "@t3tools/client-runtime/state/subscription-limit-guard"; import { useProjects, useServerConfigs } from "../../state/entities"; @@ -124,6 +131,13 @@ const DONE_STATUS_LABEL: StatusLabel = { iconTintClassName: "accent-adaptive-emerald-700-300", }; +function ThreadListV2Duration(props: { readonly startedAt: string | null }) { + const startedAtMs = Date.parse(props.startedAt ?? ""); + const nowMs = useVisibleSecondClock(Number.isFinite(startedAtMs)); + if (!Number.isFinite(startedAtMs)) return null; + return ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}`; +} + const CARD_MENU_ACTIONS: MenuAction[] = [ { id: "settle", title: "Settle", image: "checkmark" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, @@ -532,6 +546,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinned?: boolean; /** Hidden-page row: offers Unhide in place of the lifecycle actions. */ readonly hidden?: boolean; + readonly wokeAt: string | null; /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; @@ -650,10 +665,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleOpenPullRequests = () => { if (pr) onOpenThreadPullRequests(thread, pr); }; - // VoiceOver cannot focus the badge inside the accessible row, so the row - // exposes the same action. - const rowAccessibilityActions = (() => - prBadgeLabel ? [{ name: "openPullRequests", label: prBadgeLabel }] : undefined)(); const theme = useUniwindTheme(); const sidebarPane = props.pane === "sidebar"; @@ -663,6 +674,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; + const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } @@ -670,6 +682,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const recede = shouldRecedeThreadRow({ status, selected }); const timeLabel = props.timeLabel; + const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const handleAcknowledgeWoke = () => { + if (props.wokeAt === null) return; + void visitThread({ + environmentId: thread.environmentId, + input: { threadId: thread.id, visitedAt: props.wokeAt }, + }); + }; + // VoiceOver cannot focus buttons inside the accessible row. + const rowAccessibilityActions = [ + ...(prBadgeLabel ? [{ name: "openPullRequests", label: prBadgeLabel }] : []), + ...(showsWoke ? [{ name: "dismissWoke", label: "Dismiss Woke notification" }] : []), + ]; const handleDelete = () => onDeleteThread(thread); const handleRename = () => onRenameThread(thread); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); @@ -889,6 +914,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...groupMenuItems, ...projectMenuItems, ...hideMenuItems, + LEGACY_MENU_ACTIONS[0]!, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ])() as MenuAction[]; const cardMenuActions = (() => [ @@ -900,6 +926,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...groupMenuItems, ...projectMenuItems, ...hideMenuItems, + LEGACY_MENU_ACTIONS[0]!, ...CARD_MENU_ACTIONS.slice(1), ])() as MenuAction[]; const slimMenuActions = (() => [ @@ -913,6 +940,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...groupMenuItems, ...projectMenuItems, ...hideMenuItems, + LEGACY_MENU_ACTIONS[0]!, SLIM_MENU_ACTIONS[1]!, ])() as MenuAction[]; const snoozedMenuActions = (() => [ @@ -923,6 +951,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...groupMenuItems, ...projectMenuItems, ...hideMenuItems, + LEGACY_MENU_ACTIONS[0]!, SNOOZED_MENU_ACTIONS[1]!, ])() as MenuAction[]; // Hidden rows offer Unhide instead of lifecycle verbs; the thread keeps @@ -934,6 +963,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...subscriptionLimitMenuItems, ...groupMenuItems, ...projectMenuItems, + LEGACY_MENU_ACTIONS[0]!, HIDDEN_MENU_ACTIONS[1]!, ])() as MenuAction[]; const legacyMenuActions = (() => [ @@ -1088,7 +1118,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { type="monochrome" /> ) : null} - {statusLabel ? ( + {showsWoke ? ( + undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > + + + Woke + + + ) : statusLabel ? ( {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} ) : ( @@ -1299,6 +1362,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityActions={rowAccessibilityActions} onAccessibilityAction={(event) => { if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); + if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); }} onPress={() => { close(); diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.test.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.test.ts deleted file mode 100644 index ef0e770fe8ea..000000000000 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { MOBILE_THEME_IDS } from "@t3tools/shared/themePalettes"; - -import { getMobileThemeVariables } from "../../lib/mobileTheme"; -import { getThreadListV2RowAppearance as iosAppearance } from "./thread-list-v2-row-appearance"; -import { getThreadListV2RowAppearance as androidAppearance } from "./thread-list-v2-row-appearance.android"; - -describe.each([ - ["ios", iosAppearance], - ["android", androidAppearance], -] as const)("%s thread row colors", (_platform, appearanceFor) => { - it.each(MOBILE_THEME_IDS)( - "preserves active selection and uses neutral hover for %s", - (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const theme = getMobileThemeVariables(themeId, appearance); - const idle = appearanceFor(theme, true, false); - const active = appearanceFor(theme, true, true); - - expect(idle.style?.backgroundColor).toBe(theme["--color-drawer"]); - expect(idle.interactionClassName).toBe("bg-thread-hover"); - expect(idle.interactionOpacity).toBe(1); - expect(active.style?.backgroundColor).toBe(theme["--color-thread-selected"]); - expect(active.interactionOpacity).toBe(0); - expect(active.providerIconSurfaceColor).toBe(active.style?.backgroundColor); - expect(idle.foregroundClassName).toBe("text-drawer-foreground"); - expect(idle.mutedForegroundClassName).toBe("text-drawer-foreground-muted"); - - const phone = appearanceFor(theme, false, false); - expect(phone.swipeBackgroundColor).toBe(theme["--color-screen"]); - expect(phone.interactionClassName).toBe("bg-row-hover"); - expect(phone.foregroundClassName).toBe("text-foreground"); - } - }, - ); -}); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index b872f3a35a80..be63be589b97 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -81,32 +81,6 @@ describe("resolveThreadProviderInstance", () => { ).toBeUndefined(); }); - it("labels a custom instance by its id so its initials differ from the default", () => { - const environmentId = EnvironmentId.make("environment-a"); - const serverConfigs = new Map([ - [ - environmentId, - makeConfig([ - { instanceId: "codex", driver: "codex", displayName: "Codex" }, - { instanceId: "codex_personal", driver: "codex", displayName: "Codex" }, - ]), - ], - ]); - - expect( - resolveThreadProviderInstance( - serverConfigs.get(environmentId)?.providers, - makeThread(environmentId, "codex"), - )?.displayName, - ).toBe("Codex"); - expect( - resolveThreadProviderInstance( - serverConfigs.get(environmentId)?.providers, - makeThread(environmentId, "codex_personal"), - )?.displayName, - ).toBe("Codex Personal"); - }); - it("uses the current runtime owner after a provider handoff", () => { const environmentId = EnvironmentId.make("environment-a"); const serverConfigs = new Map([ @@ -139,18 +113,6 @@ describe("resolveThreadProviderInstance", () => { showBadge: true, }); }); - - it("hides the badge for a single instance with no accent color", () => { - const environmentId = EnvironmentId.make("environment-a"); - const serverConfigs = new Map([ - [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], - ]); - const thread = makeThread(environmentId, "codex"); - - expect( - resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread)?.showBadge, - ).toBe(false); - }); }); describe("createThreadRowProviderInstanceResolver", () => { @@ -205,60 +167,4 @@ describe("createThreadRowProviderInstanceResolver", () => { registry.dispose(); } }); - - const environmentId = EnvironmentId.make("environment-a"); - const serverConfigs = new Map([ - [ - environmentId, - makeConfig([ - { instanceId: "codex", driver: "codex", displayName: "Codex" }, - { instanceId: "codex_work", driver: "codex", displayName: "Codex" }, - ]), - ], - ]); - - it("hands out the same reference for repeated lookups of one instance", () => { - const resolve = createThreadRowProviderInstanceResolver( - new Map([...serverConfigs].map(([id, config]) => [id, config.providers])), - ); - const first = resolve(makeThread(environmentId, "codex")); - const second = resolve(makeThread(environmentId, "codex")); - expect(first).not.toBeNull(); - expect(second).toBe(first); - }); - - it("distinguishes instances of the same driver", () => { - const resolve = createThreadRowProviderInstanceResolver( - new Map([...serverConfigs].map(([id, config]) => [id, config.providers])), - ); - const personal = resolve(makeThread(environmentId, "codex")); - const work = resolve(makeThread(environmentId, "codex_work")); - expect(personal).not.toBeNull(); - expect(work).not.toBeNull(); - expect(work).not.toBe(personal); - expect(work?.displayName).toBe("Codex Work"); - }); - - it("hands out a new identity when the server-config generation changes", () => { - const before = createThreadRowProviderInstanceResolver( - new Map([...serverConfigs].map(([id, config]) => [id, config.providers])), - ); - const nextConfigs = new Map([ - [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], - ]); - const after = createThreadRowProviderInstanceResolver( - new Map([...nextConfigs].map(([id, config]) => [id, config.providers])), - ); - expect(after(makeThread(environmentId, "codex"))).not.toBe( - before(makeThread(environmentId, "codex")), - ); - }); - - it("resolves unknown instances to null without throwing", () => { - const resolve = createThreadRowProviderInstanceResolver( - new Map([...serverConfigs].map(([id, config]) => [id, config.providers])), - ); - expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); - expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); - }); }); diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.test.ts b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts deleted file mode 100644 index 1ac97f0781c8..000000000000 --- a/apps/mobile/src/features/threads/thread-row-emphasis.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { shouldRecedeThreadRow } from "./thread-row-emphasis"; - -describe("shouldRecedeThreadRow", () => { - it("recedes background work", () => { - expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true); - expect(shouldRecedeThreadRow({ status: "waiting", selected: false })).toBe(true); - }); - - it("keeps rows that need a human at full strength", () => { - for (const status of ["approval", "input", "failed", "limited", "ready"] as const) { - expect(shouldRecedeThreadRow({ status, selected: false })).toBe(false); - } - }); - - it("never fades the selected row", () => { - expect(shouldRecedeThreadRow({ status: "working", selected: true })).toBe(false); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-settings-options.test.ts b/apps/mobile/src/features/threads/thread-settings-options.test.ts index 7b13b0141edb..81baead620e0 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.test.ts @@ -1,7 +1,11 @@ +import { + getPromptControlledTraits, + getTraitSelectChange, +} from "@t3tools/client-runtime/model-traits"; import type { ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { runtimeModeChoicesForSupportedModes, selectableChoices } from "./thread-settings-options"; +import { selectableChoices } from "./thread-settings-options"; const effortDescriptor: Extract = { id: "effort", @@ -19,17 +23,67 @@ const effortDescriptor: Extract = }; describe("selectableChoices", () => { - it("hides prompt-injected and workflow-trigger choices, keeping declared order", () => { + // regression: #21406 bring model controls in line with web + it("includes prompt-injected and workflow choices in declared order", () => { expect(selectableChoices(effortDescriptor).map((choice) => choice.id)).toEqual([ "low", "medium", "high", + "ultrathink", + "ultracode", ]); }); }); -describe("runtimeModeChoicesForSupportedModes", () => { - it("keeps controls usable when forward-compatible decoding removes every advertised mode", () => { - expect(runtimeModeChoicesForSupportedModes([])).toHaveLength(4); +describe("prompt-controlled reasoning", () => { + it("adds Ultrathink to the prompt without persisting an injected option", () => { + const descriptors = [effortDescriptor]; + const change = getTraitSelectChange( + { ...getPromptControlledTraits(descriptors, "solve it"), descriptors }, + effortDescriptor, + "solve it", + "ultrathink", + ); + expect(change).toEqual({ prompt: "Ultrathink:\nsolve it" }); + expect( + getPromptControlledTraits(descriptors, change?.prompt ?? "").ultrathinkPromptControlled, + ).toBe(true); + expect( + getTraitSelectChange( + { ...getPromptControlledTraits(descriptors, ""), descriptors }, + effortDescriptor, + "", + "ultrathink", + ), + ).toEqual({ prompt: "Ultrathink:\n" }); + }); + + it("removes the prefix when choosing a regular effort and saves Ultracode as an option", () => { + const descriptors = [effortDescriptor]; + const prompt = "Ultrathink:\nsolve it"; + expect( + getTraitSelectChange( + { ...getPromptControlledTraits(descriptors, prompt), descriptors }, + effortDescriptor, + prompt, + "ultracode", + ), + ).toEqual({ prompt: "solve it", modelOptions: [{ id: "effort", value: "ultracode" }] }); + }); + + it("keeps body-controlled Ultrathink locked and preserves slash commands", () => { + const descriptors = [effortDescriptor]; + const prompt = "please ultrathink about this"; + const selected = { ...getPromptControlledTraits(descriptors, prompt), descriptors }; + expect(selected.ultrathinkInBodyText).toBe(true); + expect(getTraitSelectChange(selected, effortDescriptor, prompt, "low")).toBeNull(); + expect( + getTraitSelectChange( + { ...getPromptControlledTraits(descriptors, "/review"), descriptors }, + effortDescriptor, + "/review", + "ultrathink", + ), + ).toEqual({ prompt: "/review" }); }); }); diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts index 69a8d9d49e8b..1fa369d072ab 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -1,11 +1,4 @@ -import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; - -/** - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not a - * reasoning level. - */ -const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); +import type { RuntimeMode } from "@t3tools/contracts"; export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ readonly mode: RuntimeMode; @@ -51,11 +44,4 @@ export function compatibleRuntimeModeForChoices( : (choices[0]?.mode ?? runtimeMode); } -export function selectableChoices( - descriptor: Extract, -) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} +export { selectableProviderOptionChoices as selectableChoices } from "@t3tools/client-runtime/model-traits"; diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 8c65f3e6e22f..53b5874c7e3a 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -11,12 +11,7 @@ import { rememberModelOptions, withRememberedModelOptions, } from "../../state/use-model-option-memory"; -import { - canCommitPendingModel, - favoritesFirst, - modelMatchesCatalogQuery, - pendingModelAfterPress, -} from "./thread-settings-sheet-state"; +import { canCommitPendingModel, pendingModelAfterPress } from "./thread-settings-sheet-state"; function modelOption( model: string, @@ -74,74 +69,6 @@ afterEach(() => { }); describe("thread settings sheet state", () => { - it("keeps favorites in catalog order ahead of other models", () => { - const models = [ - modelOption("first"), - modelOption("second"), - modelOption("third"), - modelOption("fourth"), - ]; - const favorites = new Set([models[2]!.key, models[0]!.key]); - - expect(favoritesFirst(models, favorites).map((model) => model.selection.model)).toEqual([ - "first", - "third", - "second", - "fourth", - ]); - expect(models.map((model) => model.selection.model)).toEqual([ - "first", - "second", - "third", - "fourth", - ]); - }); - - it("matches visible model and provider terms", () => { - const model = modelOption("gpt-next"); - - expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "NEXT" })).toBe(true); - expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "codex" })).toBe(true); - expect(modelMatchesCatalogQuery({ model, providerLabel: "Codex", query: "claude" })).toBe( - false, - ); - }); - - it("treats whitespace-only catalog searches as empty", () => { - expect( - modelMatchesCatalogQuery({ - model: modelOption("gpt-next"), - providerLabel: "Codex", - query: " ", - }), - ).toBe(true); - }); - - it("matches the upstream provider's display name", () => { - const model = { - ...modelOption("opencode/claude-fable-5"), - label: "Claude Fable 5", - subtitle: "OpenCode Zen", - }; - - expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: " ZEN " })).toBe( - true, - ); - expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: "copilot" })).toBe( - false, - ); - }); - - it("clears staging when the applied model is pressed", () => { - expect( - pendingModelAfterPress({ - current: modelOption("gpt-next"), - pressed: modelOption("gpt-current"), - pressedIsApplied: true, - }), - ).toBeNull(); - }); - it("preserves in-progress edits when the staged model is pressed again", () => { rememberModelOptions("codex", "gpt-next", [{ id: "effort", value: "medium" }]); const pending = modelOption("gpt-next", [{ id: "effort", value: "high" }]); @@ -206,6 +133,7 @@ describe("thread settings sheet state", () => { ]); }); + // regression: #16816 sync model favorites and visibility across clients it("cannot select a hidden model exposed only for visibility management", () => { const pending = modelOption("gpt-next"); expect( diff --git a/apps/mobile/src/features/threads/thread-title-regeneration-menu.test.ts b/apps/mobile/src/features/threads/thread-title-regeneration-menu.test.ts deleted file mode 100644 index 6425d6361212..000000000000 --- a/apps/mobile/src/features/threads/thread-title-regeneration-menu.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; - -describe("buildThreadTitleRegenerationMenuItems", () => { - it("hides regeneration when the environment does not advertise support", () => { - expect( - buildThreadTitleRegenerationMenuItems({ supported: false, isRegenerating: false }), - ).toEqual([]); - }); - - it("offers regeneration for a supported environment", () => { - expect( - buildThreadTitleRegenerationMenuItems({ supported: true, isRegenerating: false }), - ).toEqual([ - { - id: "regenerate-title", - title: "Regenerate title", - image: "arrow.clockwise", - }, - ]); - }); - - it("shows and disables the pending state", () => { - expect( - buildThreadTitleRegenerationMenuItems({ supported: true, isRegenerating: true }), - ).toEqual([ - { - id: "regenerate-title", - title: "Regenerating…", - image: "arrow.clockwise", - attributes: { disabled: true }, - }, - ]); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-title-rename.test.ts b/apps/mobile/src/features/threads/thread-title-rename.test.ts deleted file mode 100644 index 75bc5cdc4133..000000000000 --- a/apps/mobile/src/features/threads/thread-title-rename.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveThreadTitleRename } from "./thread-title-rename"; - -describe("resolveThreadTitleRename", () => { - it("trims a changed title", () => { - expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({ - action: "rename", - title: "New title", - }); - }); - - it("rejects empty and unchanged titles", () => { - expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({ - action: "reject-empty", - }); - expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({ - action: "noop", - }); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index c5fc49172229..dd1b2fca0da5 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -7,6 +7,7 @@ import { ToolPreviewCard } from "./ToolPreviewCard"; import { Button } from "../../components/ui/Button"; import { SubagentStatusDot } from "./SubagentStatusDot"; import { ThreadSubagentGroup } from "./thread-subagent-group"; +import { ProposedPlanCard } from "./ProposedPlanCard"; import { WorkLogLabel, WorkLogBlock, @@ -498,8 +499,7 @@ interface ThreadWorkLogProps { readonly activities: ReadonlyArray; readonly anchorKey: string; readonly environmentId: EnvironmentId; - /** Directory the thread runs in; read rows show paths relative to it. */ - readonly workspaceRoot: string | null; + readonly workspaceRoot?: string | null | undefined; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly rowSizing: ReturnType; @@ -539,6 +539,18 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { return null; } + const plan = props.activities[0]?.projectedItem.item; + if (plan?.type === "proposed_plan") { + return ( + + ); + } + if (props.activities[0]?.projectedItem.item.type === "subagent") { return ; } @@ -1391,6 +1403,7 @@ export function ThreadWorkGroupToggle(props: { readonly hasFailure: boolean; readonly shimmer: boolean; readonly thought?: string | undefined; + readonly trailing?: ReactNode; readonly onToggle: () => void; }) { const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; @@ -1423,49 +1436,54 @@ export function ThreadWorkGroupToggle(props: { ) : null} - { - void Haptics.selectionAsync(); - props.onToggle(); - }} - rowSizing={props.rowSizing} - > - {props.shimmer ? ( - - ) : ( - <> - - + + { + void Haptics.selectionAsync(); + props.onToggle(); + }} + rowSizing={props.rowSizing} + > + {props.shimmer ? ( + - - {props.summary} - - )} - - + ) : ( + <> + + + + {props.summary} + + )} + + + + {props.trailing} + ); } diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts deleted file mode 100644 index 65c12910cea7..000000000000 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; - -const base = { - title: null, - prompt: "Audit the timestamps", - status: "running" as const, - result: null, - childThreadId: "thread-child" as never, -}; - -describe("resolveSubagentRowPresentation", () => { - it("leads with progress while the agent is working", () => { - const row = resolveSubagentRowPresentation({ - ...base, - progress: "Reading files", - result: "stale result", - }); - - expect(row.detail).toBe("Reading files"); - expect(row.tone).toBe("working"); - expect(row.live).toBe(true); - }); - - it("leads with the result once the agent has settled", () => { - const row = resolveSubagentRowPresentation({ - ...base, - status: "completed", - progress: "Reading files", - result: "Found two\n problems", - }); - - expect(row.detail).toBe("Found two problems"); - expect(row.tone).toBe("completed"); - expect(row.statusLabel).toBe("Completed"); - }); - - it("shows a failure's text, since that is where the error lands", () => { - const row = resolveSubagentRowPresentation({ ...base, status: "failed", result: "Timed out" }); - - expect(row.detail).toBe("Timed out"); - expect(row.tone).toBe("failed"); - }); - - it("falls back to a trimmed prompt when the agent has no title", () => { - const long = resolveSubagentRowPresentation({ ...base, prompt: "x".repeat(200) }); - const titled = resolveSubagentRowPresentation({ ...base, title: "Subagent: /root/my_worker" }); - - expect(long.title).toHaveLength(80); - expect(long.title.endsWith("...")).toBe(true); - expect(titled.title).toBe("My Worker"); - }); - - it("only offers a thread to open when the agent has one", () => { - expect(resolveSubagentRowPresentation(base).canOpenThread).toBe(true); - expect(resolveSubagentRowPresentation({ ...base, childThreadId: null }).canOpenThread).toBe( - false, - ); - }); - - it("uses the status label when there is nothing to report yet", () => { - const row = resolveSubagentRowPresentation({ ...base, status: "pending" }); - - expect(row.detail).toBeNull(); - expect(row.statusLabel).toBe("Working"); - }); - - it("bounds long result previews without dropping the agent's status", () => { - const row = resolveSubagentRowPresentation({ - ...base, - status: "failed", - result: "x".repeat(400), - }); - expect(row.detail).toBe(`${"x".repeat(280)}…`); - expect(row.statusLabel).toBe("Failed"); - }); -}); diff --git a/apps/mobile/src/features/threads/threadContentPresentation.test.ts b/apps/mobile/src/features/threads/threadContentPresentation.test.ts index f179e756fbfb..3990799d8592 100644 --- a/apps/mobile/src/features/threads/threadContentPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadContentPresentation.test.ts @@ -14,17 +14,6 @@ describe("thread content presentation", () => { ).toEqual({ kind: "ready" }); }); - it("loads missing detail inside the thread screen when connected", () => { - expect( - projectThreadContentPresentation({ - hasDetail: false, - detailError: null, - detailDeleted: false, - connectionState: "connected", - }), - ).toEqual({ kind: "loading" }); - }); - it("explains uncached detail while disconnected instead of loading forever", () => { expect( projectThreadContentPresentation({ @@ -39,19 +28,4 @@ describe("thread content presentation", () => { detail: "Reconnect this environment to load the conversation.", }); }); - - it("surfaces detail errors before presenting a loading state", () => { - expect( - projectThreadContentPresentation({ - hasDetail: false, - detailError: "The thread stream failed.", - detailDeleted: false, - connectionState: "connected", - }), - ).toEqual({ - kind: "unavailable", - title: "Could not load conversation", - detail: "The thread stream failed.", - }); - }); }); diff --git a/apps/mobile/src/features/threads/threadDragGap.test.ts b/apps/mobile/src/features/threads/threadDragGap.test.ts deleted file mode 100644 index cc37a5c80eaa..000000000000 --- a/apps/mobile/src/features/threads/threadDragGap.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { threadDragAction, threadOrderAfterMove } from "./threadOrder"; -import { threadDragGapOffset } from "./threadDragGap"; - -describe("live thread insertion gap", () => { - const offsets = [0, 48, 120, 168, 240]; - const shifts = (source: number, insertion: number) => - offsets.map((offset) => threadDragGapOffset(offset, source, 72, insertion)); - - it("moves the Active header and intervening rows up when unpinning", () => { - expect(shifts(48, 312)).toEqual([0, 0, -72, -72, -72]); - }); - it("opens a full gap below the Pinned header when pinning", () => { - expect(shifts(240, 48)).toEqual([0, 72, 72, 72, 0]); - }); - it("leaves the source gap in place for cancellation or its current destination", () => { - expect(shifts(168, 168)).toEqual([0, 0, 0, 0, 0]); - expect(shifts(168, 240)).toEqual([0, 0, 0, 0, 0]); - }); - it("moves only crossed rows for an adjacent reorder", () => { - expect(shifts(168, 312)).toEqual([0, 0, 0, 0, -72]); - expect(shifts(240, 168)).toEqual([0, 0, 0, 72, 0]); - }); -}); - -describe("drag action labels", () => { - it("names the action for each destination instead of its section", () => { - expect(threadDragAction("active", "pinned")).toBe("Pin"); - expect(threadDragAction("pinned", "active")).toBe("Unpin"); - expect(threadDragAction("settled", "active")).toBe("Unsettle"); - expect(threadDragAction("snoozed", "active")).toBe("Unsnooze"); - expect(threadDragAction("active", "settled")).toBe("Settle"); - expect(threadDragAction("pinned", "settled")).toBe("Settle"); - expect(threadDragAction("active", "active")).toBe("Reorder"); - }); - it("does not offer a parked-section reorder or snooze without a wake time", () => { - expect(threadDragAction("settled", "settled")).toBeNull(); - expect(threadDragAction("active", "snoozed")).toBeNull(); - expect( - threadOrderAfterMove(["a", "b"], "a", { - section: "settled", - targetId: null, - placement: "before", - }), - ).toBeNull(); - }); -}); diff --git a/apps/mobile/src/features/threads/threadForkNavigation.test.ts b/apps/mobile/src/features/threads/threadForkNavigation.test.ts deleted file mode 100644 index a56cb3da2e36..000000000000 --- a/apps/mobile/src/features/threads/threadForkNavigation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { waitForThreadShellReady } from "./threadForkNavigation"; - -describe("waitForThreadShellReady", () => { - it("returns true when the forked thread shell arrives before the deadline", async () => { - let elapsedMs = 0; - - const ready = await waitForThreadShellReady({ - read: () => elapsedMs >= 80, - timeoutMs: 120, - pollIntervalMs: 40, - now: () => elapsedMs, - delay: async (durationMs) => { - elapsedMs += durationMs; - }, - }); - - expect(ready).toBe(true); - }); - - it("returns false instead of navigating when the shell never arrives", async () => { - let elapsedMs = 0; - - const ready = await waitForThreadShellReady({ - read: () => false, - timeoutMs: 80, - pollIntervalMs: 40, - now: () => elapsedMs, - delay: async (durationMs) => { - elapsedMs += durationMs; - }, - }); - - expect(ready).toBe(false); - expect(elapsedMs).toBe(80); - }); -}); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 79f9b8e92273..a1aa6391d41a 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -12,7 +12,6 @@ import { type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, @@ -24,7 +23,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; @@ -38,11 +37,9 @@ import { getThreadListV2OrderedSection, threadListV2Pages, resolveThreadListPages, - isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, - resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, - resolveThreadListV2SwipeActions, + resolveThreadListV2WokeAt, sortThreadsForListV2, threadListV2ListItemsAreEqual, threadListV2RowAccessibilityLabel, @@ -66,43 +63,7 @@ function makeThread( const NOW = "2026-06-02T00:00:00.000Z"; -const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", -}; - describe("resolveThreadListV2SnoozeMenuSelection", () => { - it("accepts a displayed evening preset while its wake time is still future", () => { - const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); - const selectedAt = new Date(2026, 4, 8, 17, 0, 30); - const displayedPresets = resolveSnoozePresets(menuOpenedAt); - - const selection = resolveThreadListV2SnoozeMenuSelection({ - event: "snooze:evening", - displayedPresets, - now: selectedAt, - }); - - expect(selection).toEqual({ - _tag: "selected", - preset: displayedPresets.find((preset) => preset.id === "evening"), - }); - }); - - it("expires a displayed preset once its wake time has passed", () => { - const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 16, 59, 30)); - - expect( - resolveThreadListV2SnoozeMenuSelection({ - event: "snooze:evening", - displayedPresets, - now: new Date(2026, 4, 8, 18, 0, 1), - }), - ).toEqual({ _tag: "expired" }); - }); - it("recomputes presets that remain available instead of using old timestamps", () => { const displayedPresets = resolveSnoozePresets(new Date(2026, 4, 8, 10)); const selectedAt = new Date(2026, 4, 8, 10, 30); @@ -146,72 +107,6 @@ describe("resolveThreadListV2Status", () => { ); }); - it("distinguishes usage limits from ordinary failures and clears the label after recovery", () => { - const thread = makeThread({ - id: ThreadId.make("limited"), - title: "Limited", - runtime: { - status: "failed", - activeRunId: null, - providerInstanceId: ProviderInstanceId.make("codex"), - providerName: "Codex", - lastError: "Plan limit reached", - lastErrorClass: "usage_limit", - updatedAt: NOW, - }, - }); - expect(resolveThreadListV2Status(thread)).toBe("limited"); - expect( - resolveThreadListV2Status({ - ...thread, - runtime: { ...thread.runtime!, lastErrorClass: null }, - }), - ).toBe("failed"); - expect( - resolveThreadListV2Status({ - ...thread, - runtime: { ...thread.runtime!, status: "completed" }, - }), - ).toBe("ready"); - }); - - it("prioritizes approval over a running runtime", () => { - const thread = makeThread({ - id: ThreadId.make("t"), - title: "t", - hasPendingApprovals: true, - runtime: { - status: "running", - activeRunId: RunId.make("run-t"), - providerName: "Codex", - providerInstanceId: ProviderInstanceId.make("codex"), - lastError: null, - updatedAt: NOW, - }, - }); - expect(resolveThreadListV2Status(thread)).toBe("approval"); - }); - - it("reports waiting when presentation parks runtime idle for background tasks", () => { - expect( - resolveThreadListV2Status( - makeThread({ - id: ThreadId.make("t"), - title: "t", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], - runtime: { - status: "idle", - activeRunId: null, - providerInstanceId: ProviderInstanceId.make("codex"), - providerName: "Codex", - lastError: null, - updatedAt: NOW, - }, - }), - ), - ).toBe("waiting"); - }); - it.each([ { kind: "command", status: "ready" }, { kind: "monitor", status: "waiting" }, @@ -233,12 +128,6 @@ describe("resolveThreadListV2Status", () => { expect(threadHasUnseenCompletion(thread)).toBe(true); }, ); - - it("resolves ready for quiescent threads", () => { - expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( - "ready", - ); - }); }); describe("queued messages keep a settled thread active", () => { @@ -268,118 +157,6 @@ describe("queued messages keep a settled thread active", () => { ]); expect(layout.settledCount).toBe(1); }); - - it("includes it in the reorderable active section", () => { - expect( - getThreadListV2OrderedSection({ threads, section: "active", now: NOW, queuedThreadKeys }).map( - (thread) => thread.id, - ), - ).toEqual(["active", "settled-queued"]); - expect( - getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( - (thread) => thread.id, - ), - ).toEqual(["active"]); - }); -}); - -describe("resolveThreadListV2SwipeActions", () => { - it("offers settle and snooze for an active snoozable thread", () => { - expect( - resolveThreadListV2SwipeActions({ - variant: "card", - settlementSupported: true, - snoozeSupported: true, - snoozable: true, - }), - ).toEqual({ primary: "settle", secondary: "snooze" }); - }); - - it("offers un-settle and snooze for settled history", () => { - expect( - resolveThreadListV2SwipeActions({ - variant: "slim", - settlementSupported: true, - snoozeSupported: true, - snoozable: true, - }), - ).toEqual({ primary: "unsettle", secondary: "snooze" }); - }); - - it("omits snooze when the server or thread does not allow it", () => { - expect( - resolveThreadListV2SwipeActions({ - variant: "card", - settlementSupported: true, - snoozeSupported: false, - snoozable: true, - }), - ).toEqual({ primary: "settle", secondary: null }); - expect( - resolveThreadListV2SwipeActions({ - variant: "card", - settlementSupported: true, - snoozeSupported: true, - snoozable: false, - }), - ).toEqual({ primary: "settle", secondary: null }); - }); - - it("falls back to archive only for a pre-lifecycle server", () => { - expect( - resolveThreadListV2SwipeActions({ - variant: "card", - settlementSupported: false, - snoozeSupported: false, - snoozable: true, - }), - ).toEqual({ primary: "archive", secondary: null }); - }); - - it("offers wake and no snooze on a snoozed row", () => { - expect( - resolveThreadListV2SwipeActions({ - variant: "slim", - settlementSupported: true, - snoozeSupported: true, - snoozable: true, - snoozed: true, - }), - ).toEqual({ primary: "unsnooze", secondary: null }); - }); -}); - -describe("resolveThreadListV2SnoozeGateExpiryMs", () => { - it("reports when an unadopted turn's grace window lapses", () => { - const thread = makeThread({ - id: ThreadId.make("t"), - title: "t", - latestUserMessageAt: "2026-06-02T00:00:30.000Z", - }); - expect(resolveThreadListV2SnoozeGateExpiryMs(thread, { now: "2026-06-02T00:01:00.000Z" })).toBe( - Date.parse("2026-06-02T00:02:30.000Z"), - ); - }); - - it("returns null once the thread is snoozable or when only data can unblock it", () => { - expect( - resolveThreadListV2SnoozeGateExpiryMs( - makeThread({ id: ThreadId.make("ready"), title: "Ready" }), - { now: NOW }, - ), - ).toBe(null); - expect( - resolveThreadListV2SnoozeGateExpiryMs( - makeThread({ - id: ThreadId.make("blocked"), - title: "Blocked", - hasPendingApprovals: true, - latestUserMessageAt: NOW, - }), - { now: NOW }, - ), - ).toBe(null); - }); }); describe("sortThreadsForListV2", () => { @@ -391,15 +168,6 @@ describe("sortThreadsForListV2", () => { ]); expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); }); - - it("orders by creation time, newest first, ignoring activity", () => { - const sorted = sortThreadsForListV2([ - { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, - { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, - { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, - ]); - expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); - }); }); describe("getThreadListV2OrderedSection", () => { @@ -452,25 +220,6 @@ describe("getThreadListV2OrderedSection", () => { }); describe("buildThreadListV2Items", () => { - it("places a persisted settled thread in the settled shelf", () => { - const thread = makeThread({ - id: ThreadId.make("linked-merged"), - title: "Linked merged pull request", - linkedPullRequest, - settledOverride: "settled", - settledAt: NOW, - }); - const layout = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.settledCount).toBe(1); - expect(layout.items[0]?.variant).toBe("slim"); - }); - it("shows only the picked pages; hidden outranks a group, a group outranks the rest", () => { const threads = [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -757,49 +506,6 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("places settled pinned threads in the settled shelf", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ - id: ThreadId.make("pinned-settled"), - title: "Pinned while settled", - pinnedAt: "2026-06-01T12:00:00.000Z", - settledOverride: "settled", - settledAt: "2026-06-01T12:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); - expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); - expect(layout.settledCount).toBe(1); - }); - - it("keeps active pinned threads in the pinned block", () => { - const pinned = makeThread({ - id: ThreadId.make("pinned"), - title: "Pinned thread", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [pinned], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned" }, - variant: "card", - pinned: true, - }); - expect(layout.settledCount).toBe(0); - }); - it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ @@ -874,32 +580,6 @@ describe("buildThreadListV2Items", () => { expect(afterWake.snoozedCount).toBe(0); }); - it("classifies snooze with the second-precise clock and reports the next wake", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ - id: ThreadId.make("just-woke"), - title: "Just woke", - snoozedUntil: "2026-06-02T00:00:30.000Z", - snoozedAt: "2026-06-01T12:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("still-snoozed"), - title: "Still snoozed", - snoozedUntil: "2026-06-02T09:00:00.000Z", - snoozedAt: "2026-06-01T12:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: "2026-06-02T00:01:07.500Z", - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); - expect(layout.snoozedCount).toBe(1); - expect(layout.nextSnoozeWakeAt).toBe("2026-06-02T09:00:00.000Z"); - }); - it("builds snoozed rows between active and settled when the shelf is expanded", () => { const layout = buildThreadListV2Items({ threads: [ @@ -940,26 +620,6 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(2); }); - it("collapses to a header-only shelf", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ - id: ThreadId.make("snoozed"), - title: "Snoozed", - snoozedUntil: "2026-06-03T09:00:00.000Z", - snoozedAt: "2026-06-01T12:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items).toEqual([]); - expect(layout.snoozedCount).toBe(1); - expect(layout.snoozedShelfHeaderIndex).toBe(0); - }); - it("keeps the selected thread on a collapsed shelf", () => { const layout = buildThreadListV2Items({ threads: [ @@ -1039,105 +699,6 @@ describe("buildThreadListV2Items", () => { expect(layout.settledShelfHeaderIndex).toBe(1); }); - it("collapses settled threads to a counted shelf header", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ - id: ThreadId.make("settled"), - title: "Settled", - settledOverride: "settled", - settledAt: NOW, - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - settledShelfExpanded: false, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active"]); - expect(layout.settledCount).toBe(1); - expect(layout.settledShelfHeaderIndex).toBe(1); - }); - - it("keeps the selected settled thread visible when its shelf is collapsed", () => { - const layout = buildThreadListV2Items({ - threads: [ - makeThread({ - id: ThreadId.make("selected"), - title: "Selected", - settledOverride: "settled", - settledAt: NOW, - }), - makeThread({ - id: ThreadId.make("other"), - title: "Other", - settledOverride: "settled", - settledAt: NOW, - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - settledShelfExpanded: false, - selectedThreadKey: `${environmentId}:selected`, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["selected"]); - expect(layout.settledCount).toBe(2); - expect(layout.settledShelfHeaderIndex).toBe(0); - }); - - it("keeps cards in creation order while settled sorts by recency", () => { - const { items } = buildThreadListV2Items({ - threads: [ - makeThread({ - id: ThreadId.make("older-created"), - title: "Older", - createdAt: "2026-06-01T08:00:00.000Z", - updatedAt: NOW, - }), - makeThread({ - id: ThreadId.make("newer-created"), - title: "Newer", - createdAt: "2026-06-01T12:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); - }); - - it("sorts settled threads by their persisted settlement timestamp", () => { - const { items } = buildThreadListV2Items({ - threads: [ - makeThread({ - id: ThreadId.make("settled-newer"), - title: "Settled newer", - settledOverride: "settled", - settledAt: "2026-06-01T12:00:00.000Z", - latestUserMessageAt: "2026-06-01T08:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("settled-older"), - title: "Settled older", - settledOverride: "settled", - settledAt: "2026-06-01T10:00:00.000Z", - latestUserMessageAt: "2026-06-01T09:00:00.000Z", - }), - ], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(items.map((item) => item.thread.id)).toEqual(["settled-newer", "settled-older"]); - }); - it("keeps settled threads in the tail and filters by search query", () => { const { items } = buildThreadListV2Items({ threads: [ @@ -1161,48 +722,6 @@ describe("buildThreadListV2Items", () => { ]); }); - it("includes a thread matched by message content", () => { - const thread = makeThread({ - id: ThreadId.make("content-match"), - title: "Unrelated title", - }); - const { items } = buildThreadListV2Items({ - threads: [thread], - environmentId: null, - searchQuery: "relay reconnect", - matchedThreadKeys: new Set([ - threadSearchMatchKey({ - environmentId, - threadId: thread.id, - }), - ]), - now: NOW, - }); - - expect(items.map((item) => item.thread.id)).toEqual(["content-match"]); - }); - - it("scopes the flat list to one project", () => { - const projectId = ProjectId.make("project-1"); - const otherProjectId = ProjectId.make("project-2"); - const { items } = buildThreadListV2Items({ - threads: [ - makeThread({ id: ThreadId.make("included"), projectId, title: "Included" }), - makeThread({ - id: ThreadId.make("excluded"), - projectId: otherProjectId, - title: "Excluded", - }), - ], - environmentId: null, - projectRefs: [{ environmentId, projectId }], - searchQuery: "", - now: NOW, - }); - - expect(items.map((item) => item.thread.id)).toEqual(["included"]); - }); - it("scopes the flat list to every environment member of a logical project", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const projectId = ProjectId.make("project-1"); @@ -1301,33 +820,10 @@ describe("buildThreadListV2Items settled sort reuse", () => { .map((item) => `${item.thread.environmentId}:${item.thread.id}`); } - function countDateParses(build: () => void): number { - const spy = vi.spyOn(Date, "parse"); - try { - build(); - return spy.mock.calls.length; - } finally { - spy.mockRestore(); - } - } - const older = makeSettled("older", "2026-06-01T01:00:00.000Z"); const newer = makeSettled("newer", "2026-06-01T03:00:00.000Z"); const middle = makeSettled("middle", "2026-06-01T02:00:00.000Z"); - it("orders settled rows newest first regardless of input order", () => { - expect(settledOrder([older, newer, middle])).toEqual([ - `${environmentId}:newer`, - `${environmentId}:middle`, - `${environmentId}:older`, - ]); - expect(settledOrder([middle, older, newer])).toEqual([ - `${environmentId}:newer`, - `${environmentId}:middle`, - `${environmentId}:older`, - ]); - }); - it("re-sorts when a settled row is replaced, added, or removed", () => { expect(settledOrder([older, newer])).toEqual([ `${environmentId}:newer`, @@ -1349,37 +845,6 @@ describe("buildThreadListV2Items settled sort reuse", () => { ]); }); - it("re-sorts when the same settled rows arrive in a different order", () => { - const first = makeSettled("twin", "2026-06-01T01:00:00.000Z"); - const second = makeSettled("twin", "2026-06-01T01:00:00.000Z", { - environmentId: otherEnvironmentId, - }); - expect(settledOrder([first, second])).toEqual([ - `${environmentId}:twin`, - `${otherEnvironmentId}:twin`, - ]); - expect(settledOrder([second, first])).toEqual([ - `${otherEnvironmentId}:twin`, - `${environmentId}:twin`, - ]); - }); - - it("skips the settled sort when only unsettled rows change", () => { - const settled = Array.from({ length: 50 }, (_, index) => - makeSettled(`settled-${index}`, `2026-06-01T00:${String(index).padStart(2, "0")}:00.000Z`), - ); - const streaming = makeThread({ id: ThreadId.make("streaming"), title: "Streaming" }); - const baseline = countDateParses(() => settledOrder([streaming, ...settled])); - const updated = { ...streaming, updatedAt: "2026-06-01T23:00:00.000Z" }; - let order: string[] = []; - const reused = countDateParses(() => { - order = settledOrder([updated, ...settled]); - }); - - expect(order).toEqual(settled.toReversed().map((thread) => `${environmentId}:${thread.id}`)); - expect(baseline - reused).toBeGreaterThanOrEqual(settled.length); - }); - it("recomputes when builds alternate between environments", () => { const remoteOlder = makeSettled("remote-older", "2026-06-01T01:30:00.000Z", { environmentId: otherEnvironmentId, @@ -1498,36 +963,6 @@ describe("buildThreadListV2ListItems", () => { ).toEqual([true, true, true, false, null, false]); }); - it("puts queued tasks above active rows when nothing has settled yet", () => { - const activeOnly = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "active" })], - environmentId: null, - searchQuery: "", - now: NOW, - }); - const items = buildThreadListV2ListItems({ - items: activeOnly.items, - pendingTasks: [makePendingTask("queued-1")], - }); - - expect(items.map((item) => item.type)).toEqual(["v2-pending", "v2-thread"]); - }); - - it("keeps the settled shelf between active and settled rows when nothing is queued", () => { - const items = buildThreadListV2ListItems({ - items: layout.items, - pendingTasks: [], - settledCount: layout.settledCount, - settledShelfHeaderIndex: layout.settledShelfHeaderIndex, - }); - - expect(items.map((item) => item.key)).toEqual([ - `v2-thread:${environmentId}:active`, - "v2-settled-shelf", - `v2-thread:${environmentId}:settled`, - ]); - }); - it("places queued tasks above active rows and a collapsed snoozed shelf", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ @@ -1659,12 +1094,6 @@ describe("pending mobile thread moves", () => { } }); - it("keeps search results in the full pending section order", () => { - const { rows, assignments, pending, update } = fixture(); - const current = update(update(rows, assignments[0]!), assignments[1]!); - expect(layout(current, pending, "match")).toEqual(["c", "b"]); - }); - it("releases for real section membership and foreign key changes", () => { const { rows, pending } = fixture(); expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull(); @@ -1783,20 +1212,6 @@ describe("mobile move availability", () => { expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true); }, ); - - it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => { - const ordered = rows("active", [null, null, "bb", "dd", "ff"]); - const plan = createThreadMovePlanner({ - ordered, - section: "active", - reorderableEnvironmentIds: new Set([environmentId]), - }); - const assignments = plan(`${environmentId}:move-4`, "up"); - expect(assignments).toHaveLength(1); - expect(assignments![0]!.id).toBe(`${environmentId}:move-4`); - expect(assignments![0]!.orderKey > "bb").toBe(true); - expect(assignments![0]!.orderKey < "dd").toBe(true); - }); }); describe("thread drag destinations", () => { @@ -1815,18 +1230,6 @@ describe("thread drag destinations", () => { ).toEqual(["hidden", "b", "a", "c"]); }); - it("rejects missing, self, and unchanged destinations", () => { - for (const targetId of ["missing", "a", "b"]) { - expect( - threadOrderAfterMove(["a", "b", "c"], "a", { - targetId, - placement: "before", - }), - ).toBeNull(); - } - expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull(); - }); - it.each(["active", "pinned"] as const)( "persists a dropped %s row and holds its order until confirmed", (section) => { @@ -1987,15 +1390,6 @@ describe("cross-section thread drops", () => { unsnooze: false, }); }); - it("does not send lifecycle commands for an ordinary Active reorder", () => { - expect( - threadDropLifecycle( - makeThread({ id: ThreadId.make("active"), title: "active" }), - "active", - NOW, - ), - ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); - }); }); it("excludes subagents from navigation, search and ordering while retaining user forks", () => { @@ -2030,46 +1424,6 @@ const BASE_MS = Date.parse(NOW); const isoAt = (ms: number) => new Date(ms).toISOString(); const MINUTE_MS = 60_000; -function runningRuntime() { - return { - status: "running" as const, - providerName: "Codex", - providerInstanceId: ProviderInstanceId.make("codex"), - runtimeMode: "full-access" as const, - activeRunId: null, - lastError: null, - updatedAt: NOW, - }; -} - -function buildTickThreads() { - return { - ready: makeThread({ - id: ThreadId.make("tick-ready"), - title: "tick ready", - latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), - }), - approval: makeThread({ - id: ThreadId.make("tick-approval"), - title: "tick approval", - hasPendingApprovals: true, - latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), - }), - settled: makeThread({ - id: ThreadId.make("tick-settled"), - title: "tick settled", - settledOverride: "settled", - settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), - }), - snoozed: makeThread({ - id: ThreadId.make("tick-snoozed"), - title: "tick snoozed", - snoozedAt: isoAt(BASE_MS - MINUTE_MS), - snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS), - }), - }; -} - function buildTickList( threads: ReadonlyArray, clockMs: number, @@ -2147,24 +1501,6 @@ describe("threadListV2ListItemsAreEqual", () => { } }); - it("notices a replaced thread shell", () => { - const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" }); - const rebuilt = buildThreadListV2Items({ - threads: [replacement], - environmentId: null, - searchQuery: "", - now: NOW, - }); - const next = buildThreadListV2ListItems({ - items: rebuilt.items, - pendingTasks: [], - snoozeLabelNow: NOW, - }); - const previousThread = build().find((item) => item.type === "v2-thread")!; - const nextThread = next.find((item) => item.type === "v2-thread")!; - expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false); - }); - it("notices a changed wake countdown label", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ @@ -2203,27 +1539,6 @@ describe("threadListV2ListItemsAreEqual", () => { expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); }); - it("notices shelf count, expansion, and loading-disabled changes", () => { - const shelf = { - type: "v2-settled-shelf", - key: "v2-settled-shelf", - count: 2, - expanded: true, - disabled: false, - } as const; - expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true); - expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false); - expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false); - expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false); - }); - - it("treats different item kinds as unequal", () => { - const built = build(); - const threadItem = built.find((item) => item.type === "v2-thread")!; - const pendingItem = built.find((item) => item.type === "v2-pending")!; - expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false); - }); - it("notices a trailing-divider flip caused by a neighbour change", () => { const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" }); const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" }); @@ -2263,198 +1578,97 @@ describe("threadListV2ListItemsAreEqual", () => { }); }); -describe("isThreadListV2ListItem", () => { - it("narrows the v2 kinds and rejects the legacy discriminators", () => { - expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true); - expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true); - expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true); - expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true); - expect(isThreadListV2ListItem({ type: "thread" })).toBe(false); - expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false); - }); -}); - -describe("buildThreadListV2ListItems clock scoping", () => { - const allEnvironments = new Set([environmentId]); - const tickQueued = () => [makePendingTask("tick-queued")]; - - it("carries the snooze menu clock on every row whose swipe menu offers presets", () => { - const threads = buildTickThreads(); - const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), { - snoozeEnvironmentIds: allEnvironments, - }); - const byKey = itemsByThreadKey(items); - const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!; - const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!; - const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!; - const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; - expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW); - expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW); - expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined(); - expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined(); - }); - - it("keeps the snooze menu clock off rows on servers without the capability", () => { - const threads = buildTickThreads(); - const items = buildTickList([threads.ready], BASE_MS, [], { - snoozeEnvironmentIds: new Set(), - }); - const ready = items.find((item) => item.type === "v2-thread")!; - expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined(); +describe("thread list Woke marker", () => { + const wokeAt = isoAt(BASE_MS - MINUTE_MS); + const thread = makeThread({ + id: ThreadId.make("woke"), + title: "Woke", + latestUserMessageAt: isoAt(BASE_MS - 90 * MINUTE_MS), + snoozedAt: isoAt(BASE_MS - 60 * MINUTE_MS), + snoozedUntil: wokeAt, + lastVisitedAt: null, }); - it("blanks the precomputed time for rows that render a label instead", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(BASE_MS); - const working = makeThread({ - id: ThreadId.make("tick-working"), - title: "tick working", - latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), - runtime: runningRuntime(), - }); - const threads = buildTickThreads(); - const items = buildTickList( - [threads.ready, working, threads.settled, threads.snoozed], - BASE_MS, - tickQueued(), - ); - const byKey = itemsByThreadKey(items); - const label = (key: string) => { - const item = byKey.get(key)!; - return item.type === "v2-thread" ? item.timeLabel : ""; - }; - expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m"); - expect(label(`v2-thread:${environmentId}:tick-working`)).toBe(""); - expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d"); - expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe(""); - const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; - expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h"); - } finally { - vi.useRealTimers(); - } + it("keeps a timer wake through a plain visit until acknowledgement reaches its timestamp", () => { + expect(resolveThreadListV2WokeAt(thread, { now: NOW })).toBe(wokeAt); + expect( + resolveThreadListV2WokeAt( + { ...thread, lastVisitedAt: isoAt(BASE_MS - 30 * MINUTE_MS) }, + { now: NOW }, + ), + ).toBe(wokeAt); + expect( + resolveThreadListV2WokeAt({ ...thread, lastVisitedAt: wokeAt }, { now: NOW }), + ).toBeNull(); + expect(resolveThreadListV2WokeAt({ ...thread, lastVisitedAt: "invalid" }, { now: NOW })).toBe( + wokeAt, + ); }); -}); -describe("thread list v2 minute tick invalidation", () => { - it("only invalidates rows whose clock-driven content moved", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(BASE_MS); - const threads = buildTickThreads(); - const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed]; - const pendingTasks = [makePendingTask("tick-queued")]; - const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks); - vi.setSystemTime(BASE_MS + MINUTE_MS); - const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks); - - expect(atStart.length).toBe(atNextMinute.length); - const invalidated: string[] = []; - for (let index = 0; index < atStart.length; index += 1) { - if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) { - invalidated.push(atStart[index]!.key); - } - } - expect(invalidated).toEqual([ - `v2-thread:${environmentId}:tick-ready`, - `v2-thread:${environmentId}:tick-settled`, - ]); - } finally { - vi.useRealTimers(); - } + it("stays hidden while snoozed, after settling, and on servers without visited tracking", () => { + expect(resolveThreadListV2WokeAt(thread, { now: isoAt(BASE_MS - 2 * MINUTE_MS) })).toBeNull(); + expect( + resolveThreadListV2WokeAt({ ...thread, settledOverride: "settled" }, { now: NOW }), + ).toBeNull(); + expect( + resolveThreadListV2WokeAt({ ...thread, lastVisitedAt: undefined }, { now: NOW }), + ).toBeNull(); + expect(resolveThreadListV2WokeAt({ ...thread, snoozedUntil: null }, { now: NOW })).toBeNull(); }); - it("keeps unread completion labels stable across a minute tick", () => { - const unread = makeThread({ - id: ThreadId.make("tick-unread"), - title: "Unread completion", - lastVisitedAt: isoAt(BASE_MS - 10 * MINUTE_MS), + it("keeps an early failure wake acknowledged after the scheduled time passes", () => { + const completedAt = isoAt(BASE_MS - 2 * MINUTE_MS); + const failed = makeThread({ + ...thread, + snoozedUntil: isoAt(BASE_MS + MINUTE_MS), + runtime: { + status: "failed", + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: "Run failed", + updatedAt: completedAt, + }, latestRun: { - runId: RunId.make("tick-unread-run"), - status: "completed", - requestedAt: isoAt(BASE_MS - 6 * MINUTE_MS), - startedAt: isoAt(BASE_MS - 6 * MINUTE_MS), - completedAt: isoAt(BASE_MS - 5 * MINUTE_MS), + runId: RunId.make("woke-run"), + status: "failed", + requestedAt: isoAt(BASE_MS - 3 * MINUTE_MS), + startedAt: isoAt(BASE_MS - 3 * MINUTE_MS), + completedAt, assistantMessageId: null, }, }); - const options = { snoozeEnvironmentIds: new Set() }; - const first = buildTickList([unread], BASE_MS, [], options)[0]!; - const next = buildTickList([unread], BASE_MS + MINUTE_MS, [], options)[0]!; - expect(first.type === "v2-thread" && first.timeLabel).toBe(""); - expect(threadListV2ListItemsAreEqual(first, next)).toBe(true); + expect(resolveThreadListV2WokeAt(failed, { now: NOW })).toBe(completedAt); + expect( + resolveThreadListV2WokeAt( + { ...failed, lastVisitedAt: completedAt }, + { now: isoAt(BASE_MS + 2 * MINUTE_MS) }, + ), + ).toBeNull(); }); - it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(BASE_MS); - const staleReady = makeThread({ - id: ThreadId.make("tick-stale"), - title: "tick stale", - latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS), - }); - const atStart = buildTickList([staleReady], BASE_MS, [], { - snoozeEnvironmentIds: new Set(), - }); - vi.setSystemTime(BASE_MS + MINUTE_MS); - const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { + it("updates a recycled row at the wake boundary even without a new shell or minute tick", () => { + const waking = { ...thread, snoozedUntil: isoAt(BASE_MS + 30_000) }; + const layout = (now: string) => + buildThreadListV2Items({ + threads: [waking], + environmentId: null, + searchQuery: "", + now, snoozeEnvironmentIds: new Set(), }); - expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - it("re-renders the snoozed countdown row when the wake label advances", () => { - vi.useFakeTimers(); - try { - const wakeAt = BASE_MS + 120 * MINUTE_MS; - const snoozed = makeThread({ - id: ThreadId.make("tick-wake"), - title: "tick wake", - snoozedAt: isoAt(BASE_MS - MINUTE_MS), - snoozedUntil: isoAt(wakeAt), + const list = (now: string) => + buildThreadListV2ListItems({ + items: layout(now).items, + pendingTasks: [], + snoozeLabelNow: isoAt(BASE_MS), }); - const wakeKey = `v2-thread:${environmentId}:tick-wake`; - const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!; - vi.setSystemTime(BASE_MS); - const atStart = buildTickList([snoozed], BASE_MS, []); - vi.setSystemTime(BASE_MS + MINUTE_MS); - const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []); - expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true); - vi.setSystemTime(BASE_MS + 61 * MINUTE_MS); - const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []); - const oneHourRow = wakeRow(oneHour); - expect(oneHourRow.type).toBe("v2-thread"); - expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m"); - expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe("buildThreadListV2ListItems trailing dividers", () => { - it("follows the final neighbour order, not the pre-splice blocks", () => { - const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" }); - const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" }); - const layout = buildThreadListV2Items({ - threads: [activeA, activeB], - environmentId: null, - searchQuery: "", - now: NOW, - }); - const items = buildThreadListV2ListItems({ - items: layout.items, - pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")], - snoozeLabelNow: NOW, - }); - const dividers = items.map((item) => - item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a", - ); - expect(dividers).toEqual([true, true, true, false]); + const before = list(NOW)[0]!; + const after = list(isoAt(BASE_MS + 37_500))[0]!; + expect(before.type === "v2-thread" && before.item.wokeAt).toBeNull(); + expect(after.type === "v2-thread" && after.item.wokeAt).toBe(waking.snoozedUntil); + expect(after.type === "v2-thread" && after.timeLabel).toBe(""); + expect(threadListV2ListItemsAreEqual(before, after)).toBe(false); }); }); @@ -2534,46 +1748,6 @@ describe("buildThreadListV2ListItems row-state stamps", () => { expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false); expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true); }); - - it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => { - vi.useFakeTimers(); - try { - vi.setSystemTime(BASE_MS); - const atStart = buildTickList([settledThread], BASE_MS, [], { - snoozeEnvironmentIds: allEnvironments, - }); - vi.setSystemTime(BASE_MS + MINUTE_MS); - const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], { - snoozeEnvironmentIds: allEnvironments, - }); - const rowAtStart = atStart.find((item) => item.type === "v2-thread")!; - const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!; - expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim"); - expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS)); - expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe( - isoAt(BASE_MS + MINUTE_MS), - ); - expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it("stamps the shelf loading-disabled state so recycled headers refresh", () => { - const loading = buildTickList([settledThread], BASE_MS, [], { - shelfPreferencesLoading: true, - snoozeEnvironmentIds: allEnvironments, - }); - const loaded = buildTickList([settledThread], BASE_MS, [], { - shelfPreferencesLoading: false, - snoozeEnvironmentIds: allEnvironments, - }); - const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!; - const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!; - expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true); - expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false); - expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); - }); }); describe("Working section beta", () => { @@ -2645,14 +1819,6 @@ describe("Working section beta", () => { expect(off.workingCount).toBe(0); }); - it("shows working rows as cards when expanded, or only the selected one when collapsed", () => { - expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({ - thread: { id: "working" }, - variant: "card", - }); - expect(ids(build({ selectedThreadKey: `${environmentId}:working` })).at(-1)).toBe("working"); - }); - it("orders the inbox by the latest return this device observed", () => { const layout = build({ inboxReturnAt: (thread) => @@ -2750,3 +1916,43 @@ describe("threadListV2RowAccessibilityLabel", () => { ).toBe("Fix login, 1 draft"); }); }); + +it("keeps wake notifications on grouped and automation rows, but suppresses hidden rows", () => { + const wokeAt = "2026-06-01T23:00:00.000Z"; + const wakeState = { + snoozedUntil: wokeAt, + snoozedAt: "2026-06-01T12:00:00.000Z", + lastVisitedAt: null, + }; + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + ...wakeState, + id: ThreadId.make("group-woke"), + title: "Grouped", + groupName: "Ops", + }), + makeThread({ + ...wakeState, + id: ThreadId.make("automation-woke"), + title: "Automation", + scheduledTaskId: "automation", + }), + makeThread({ + ...wakeState, + id: ThreadId.make("hidden-woke"), + title: "Hidden", + hiddenAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + pages: ["group:Ops", "automations", "hidden"], + }); + expect(layout.items.map(({ thread, wokeAt }) => [thread.id, wokeAt])).toEqual([ + ["group-woke", wokeAt], + ["automation-woke", wokeAt], + ["hidden-woke", null], + ]); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index b194f5f0c097..ae3e3826f76e 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -6,6 +6,7 @@ import { QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, + threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -162,6 +163,20 @@ export function threadHasUnseenCompletion( return completedAtMs > lastVisitedAtMs; } +export function resolveThreadListV2WokeAt( + thread: EnvironmentThreadShell, + options: { readonly now: string }, +): string | null { + // Older servers cannot persist an acknowledgement. + if (thread.lastVisitedAt === undefined || thread.settledOverride === "settled") return null; + const wokeAt = threadWokeAt(thread, options); + const wokeAtMs = Date.parse(wokeAt ?? ""); + const lastVisitedAtMs = Date.parse(thread.lastVisitedAt ?? ""); + return !Number.isNaN(wokeAtMs) && (Number.isNaN(lastVisitedAtMs) || lastVisitedAtMs < wokeAtMs) + ? wokeAt + : null; +} + export function resolveThreadListV2Status( thread: Pick< EnvironmentThreadShell, @@ -470,6 +485,7 @@ export interface ThreadListV2Item { readonly hidden: boolean; /** Group-section row: never part of an arrangeable section. */ readonly grouped: boolean; + readonly wokeAt: string | null; readonly isLast: boolean; } @@ -623,6 +639,7 @@ export function threadListV2ListItemsAreEqual( previous.item.pinned === item.item.pinned && previous.item.hidden === item.item.hidden && previous.item.grouped === item.item.grouped && + previous.item.wokeAt === item.item.wokeAt && previous.snoozeWakeLabelText === item.snoozeWakeLabelText && previous.timeLabel === item.timeLabel && previous.snoozePresetMinute === item.snoozePresetMinute && @@ -682,7 +699,9 @@ function resolveThreadListV2ItemTimeLabel( if (showSnoozeWakeLabel) return ""; if ( variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread)) + (resolveThreadListV2Status(thread) !== "ready" || + item.wokeAt !== null || + threadHasUnseenCompletion(thread)) ) return ""; const settledTimestamp = @@ -1057,6 +1076,7 @@ export function buildThreadListV2Items(input: { pinned: true, hidden: false, grouped: false, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -1068,6 +1088,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: false, grouped: false, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -1080,6 +1101,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: false, grouped: false, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -1105,6 +1127,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: false, grouped: true, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -1124,6 +1147,7 @@ export function buildThreadListV2Items(input: { hidden: false, // Like a group's rows, never part of an arrangeable section. grouped: true, + wokeAt: resolveThreadListV2WokeAt(thread, { now }), isLast: false, }); } @@ -1136,6 +1160,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: false, grouped: false, + wokeAt: null, isLast: false, }); } @@ -1152,6 +1177,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: true, grouped: false, + wokeAt: null, isLast: false, }); } @@ -1164,6 +1190,7 @@ export function buildThreadListV2Items(input: { pinned: false, hidden: false, grouped: false, + wokeAt: null, isLast: false, }); } diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts index 9f4bdfa07332..379ccaee40b2 100644 --- a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts +++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts @@ -183,16 +183,6 @@ describe("computeThreadMoveAvailability matches the reference planner", () => { expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false }); }); - it("denies single-row sections on both directions", () => { - const rows = [makeRow("t0", "env-w", "a", true)]; - const batch = computeThreadMoveAvailability({ - ordered: rows, - section: "pinned", - reorderableEnvironmentIds: WRITABLE, - }); - expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false }); - }); - it("answers empty and single-row sections without consulting hidden rows", () => { const hidden = [makeRow("h0", "env-w", "b", true), makeRow("h1", "env-x", null, true)]; expect( @@ -256,16 +246,4 @@ describe("computeThreadMoveAvailability matches the reference planner", () => { const reference = referenceAvailability(ordered, allThreads, WRITABLE); expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); }); - - it("agrees on an adversarial section of consecutive single-char keys", () => { - const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true)); - const batch = computeThreadMoveAvailability({ - ordered: rows, - allThreads: rows, - section: "pinned", - reorderableEnvironmentIds: WRITABLE, - }); - const reference = referenceAvailability(rows, rows, WRITABLE); - expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); - }); }); diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts index baa1cddd3391..d8a7558fe072 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts @@ -5,13 +5,9 @@ import { formatAssistantCitationHref } from "@t3tools/shared/assistantCitations" import { describe, expect, it } from "vite-plus/test"; import { - REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, - buildCancelQueuedRunCommand, - resolveQueueDragBeforeRunId, resolveThreadQueueRowControls, resolveQueueDropBeforeRunId, } from "./threadQueueControlPresentation"; -import { threadDragGapOffset } from "./threadDragGap"; describe("threadQueueControlPresentation", () => { it("shows an assistant quote as its quoted text", () => { @@ -40,24 +36,6 @@ describe("threadQueueControlPresentation", () => { ); }); - it("preserves queue reorder and steer controls with removal", () => { - const controls = resolveThreadQueueRowControls({ - busy: false, - canPromoteToSteer: true, - canReorder: true, - index: 1, - queuedCount: 3, - text: "Please review the follow-up change.", - }); - - expect(controls.displayText).toBe("Please review the follow-up change."); - expect(controls.canMoveUp).toBe(true); - expect(controls.canMoveDown).toBe(true); - expect(controls.canSteer).toBe(true); - expect(controls.canDismiss).toBe(true); - expect(controls.dismissAccessibilityLabel).toBe(REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL); - }); - it("shows context, mention and quote chips as their labels", () => { const file = formatComposerContextReference({ kind: "mention", @@ -87,32 +65,6 @@ describe("threadQueueControlPresentation", () => { expect(controls.displayText).toBe("Use the cache in README.md and api.ts"); }); - it("disables edge reorder controls and busy dismissal", () => { - const first = resolveThreadQueueRowControls({ - busy: false, - canPromoteToSteer: false, - canReorder: true, - index: 0, - queuedCount: 2, - text: "First", - }); - const busy = resolveThreadQueueRowControls({ - busy: true, - canPromoteToSteer: true, - canReorder: true, - index: 0, - queuedCount: 1, - text: "Queued message", - }); - - expect(first.canMoveUp).toBe(false); - expect(first.canMoveDown).toBe(true); - expect(first.canSteer).toBe(false); - expect(busy.canDismiss).toBe(false); - expect(busy.canMoveUp).toBe(false); - expect(busy.canSteer).toBe(false); - }); - it("keeps the row already open in the composer from being reopened or steered", () => { const editing = resolveThreadQueueRowControls({ busy: false, @@ -130,22 +82,6 @@ describe("threadQueueControlPresentation", () => { expect(editing.canMoveUp).toBe(true); expect(editing.canDismiss).toBe(true); }); - - it("builds cancelQueuedRun command arguments for removal", () => { - expect( - buildCancelQueuedRunCommand({ - environmentId: "environment:test" as never, - runId: "run:queued" as never, - threadId: "thread:test" as never, - }), - ).toEqual({ - environmentId: "environment:test", - input: { - runId: "run:queued", - threadId: "thread:test", - }, - }); - }); }); describe("queue drag insertion", () => { @@ -161,24 +97,6 @@ describe("queue drag insertion", () => { expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, -300)).toBe("first"); }); - it("opens the destination gap while the dragged row crosses other rows", () => { - const offsets = (runId: (typeof rows)[number]["id"], translation: number) => { - const before = resolveQueueDragBeforeRunId(rows, runId, translation); - if (before === undefined) return; - const source = rows.find((row) => row.id === runId)!; - const last = rows.at(-1)!; - const insertion = - before === null ? last.y + last.height : rows.find((row) => row.id === before)!.y; - return rows.map((row) => threadDragGapOffset(row.y, source.y, source.height, insertion)); - }; - - expect(resolveQueueDragBeforeRunId(rows, rows[0]!.id, 0)).toBe(rows[1]!.id); - expect(offsets(rows[0]!.id, 140)).toEqual([0, -80, 0]); - expect(offsets(rows[0]!.id, 300)).toEqual([0, -80, -80]); - expect(offsets(rows[2]!.id, -300)).toEqual([80, 80, 0]); - expect(offsets(rows[1]!.id, 0)).toEqual([0, 0, 0]); - }); - it("does not send a reorder for an unchanged or unmeasured drop", () => { expect(resolveQueueDropBeforeRunId(rows, rows[1]!.id, 0)).toBeUndefined(); expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, 20)).toBeUndefined(); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 123311d9c138..3232ccf1b193 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -6,7 +6,6 @@ import { ProviderInstanceId, ThreadId, type OrchestrationV2Subagent, - type ServerProvider, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { act, createElement } from "react"; @@ -38,9 +37,9 @@ vi.mock("../../state/use-atom-command", () => ({ import { getComposerDraftSnapshot, setComposerDraftContext } from "../../state/use-composer-drafts"; import { + useComposerCommandMenu, buildComposerSlashCommandItems, resolveComposerCommandSelection, - useComposerCommandMenu, } from "./use-composer-command-menu"; describe("mobile slash commands", () => { @@ -76,18 +75,6 @@ describe("mobile slash commands", () => { }, ); - it("does not offer a native command inside the message", () => { - expect( - buildComposerSlashCommandItems({ - query: "plan", - atMessageStart: false, - hasThread: false, - allowInteractionMode: true, - selectedProviderStatus: antigravity, - }), - ).toEqual([]); - }); - it("still applies the T3 plan command for supported providers", () => { const items = buildComposerSlashCommandItems({ query: "plan", @@ -121,216 +108,6 @@ describe("mobile slash commands", () => { }); }); -describe("workspace command discovery retry", () => { - let root: Root; - const environmentId = EnvironmentId.make("test-environment"); - const instanceId = ProviderInstanceId.make("claude"); - const provider = { - instanceId, - driver: ProviderDriverKind.make("claude"), - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - workspaceSnapshots: [ - { - cwd: "/project-a", - checkedAt: "2026-01-01T00:00:00.000Z", - slashCommandsPending: true, - slashCommands: [{ name: "compact" }], - skills: [], - }, - ], - } satisfies ServerProvider; - - function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) { - useComposerCommandMenu({ - draftMessage: "/project", - ownerKey: null, - environmentId, - projectCwd: cwd, - selectedProviderStatus: status, - hasThread: false, - hasCompactableConversation: false, - onChangeDraftMessage: () => {}, - }); - return null; - } - - beforeEach(() => { - vi.useFakeTimers(); - refreshProviders.mockReset(); - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } }); - const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; - const container = { - nodeType: 1, - tagName: "DIV", - namespaceURI: "http://www.w3.org/1999/xhtml", - ownerDocument: document, - addEventListener() {}, - removeEventListener() {}, - }; - vi.stubGlobal("document", document); - vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - root = createRoot(container as unknown as HTMLElement); - }); - - afterEach(async () => { - await act(async () => { - root.unmount(); - }); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("retries partial commands after the cooldown without editing the draft", async () => { - const recovered = { - ...provider, - workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }], - }; - refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } }); - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a" })); - }); - expect(refreshProviders).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTimeAsync(9_999)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTimeAsync(1)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - expect(refreshProviders).toHaveBeenLastCalledWith({ - environmentId, - input: { instanceId, cwd: "/project-a" }, - }); - await act(() => vi.advanceTimersByTimeAsync(20_000)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - }); - - it.each(["pi", "acpRegistry"])( - "does not poll a healthy %s workspace without discovery", - async (driver) => { - const unsupported = { - ...provider, - driver: ProviderDriverKind.make(driver), - workspaceSnapshots: [], - }; - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } }); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a", status: unsupported })); - }); - expect(refreshProviders).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTimeAsync(30_000)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }, - ); - - it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => { - const missing = { ...provider, workspaceSnapshots: [] }; - const recovered = { - ...provider, - workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ - ...snapshot, - slashCommandsPending: false, - })), - }; - refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } }); - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a", status: missing })); - }); - await act(() => vi.advanceTimersByTimeAsync(5_000)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a", status: provider })); - }); - await act(() => vi.advanceTimersByTimeAsync(4_999)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - await act(() => vi.advanceTimersByTimeAsync(1)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - await act(() => vi.advanceTimersByTimeAsync(20_000)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - }); - - it("does not retry when a concurrent scan publishes a complete workspace", async () => { - const missing = { ...provider, workspaceSnapshots: [] }; - const recovered = { - ...provider, - workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ - ...snapshot, - slashCommandsPending: false, - })), - }; - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } }); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a", status: missing })); - }); - await act(() => vi.advanceTimersByTimeAsync(5_000)); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a", status: recovered })); - }); - await act(() => vi.advanceTimersByTimeAsync(30_000)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it("retries a rejected refresh while published discovery remains pending", async () => { - const recovered = { - ...provider, - workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ - ...snapshot, - slashCommandsPending: false, - })), - }; - refreshProviders.mockRejectedValueOnce(new Error("Connection lost")); - refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } }); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a" })); - }); - await act(() => vi.advanceTimersByTimeAsync(10_000)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - await act(() => vi.advanceTimersByTimeAsync(20_000)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - }); - - it("cancels the old workspace retry and does not duplicate an in-flight request", async () => { - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a" })); - }); - refreshProviders.mockImplementationOnce(() => new Promise(() => {})); - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-b" })); - }); - expect(refreshProviders).toHaveBeenCalledTimes(2); - await act(() => vi.advanceTimersByTimeAsync(20_000)); - expect(refreshProviders).toHaveBeenCalledTimes(2); - expect(refreshProviders).toHaveBeenLastCalledWith({ - environmentId, - input: { instanceId, cwd: "/project-b" }, - }); - expect(vi.getTimerCount()).toBe(0); - }); - - it("cancels a scheduled retry when the composer unmounts", async () => { - await act(async () => { - root.render(createElement(Probe, { cwd: "/project-a" })); - }); - await act(async () => { - root.unmount(); - }); - await act(() => vi.advanceTimersByTimeAsync(20_000)); - expect(refreshProviders).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); -}); - describe("@ menu agents", () => { let root: Root; const environmentId = EnvironmentId.make("test-environment"); diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.test.tsx b/apps/mobile/src/features/threads/use-visible-second-clock.test.tsx deleted file mode 100644 index 20e622a08874..000000000000 --- a/apps/mobile/src/features/threads/use-visible-second-clock.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { act, useEffect, useLayoutEffect, useSyncExternalStore } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const harness = vi.hoisted(() => { - const listeners = new Set<() => void>(); - const appStateListeners = new Set<(state: string) => void>(); - const state = { focused: true, appState: "active" }; - return { - state, - appStateListeners, - subscribe(listener: () => void) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - setFocused(focused: boolean) { - state.focused = focused; - for (const listener of listeners) listener(); - }, - setAppState(next: string) { - state.appState = next; - for (const listener of appStateListeners) listener(next); - }, - }; -}); - -vi.mock("@react-navigation/native", () => ({ - useIsFocused: () => useSyncExternalStore(harness.subscribe, () => harness.state.focused), -})); -vi.mock("react-native", () => ({ - AppState: { - get currentState() { - return harness.state.appState; - }, - addEventListener: (_event: "change", listener: (state: string) => void) => { - harness.appStateListeners.add(listener); - return { remove: () => harness.appStateListeners.delete(listener) }; - }, - }, -})); - -import { useVisibleSecondClock } from "./use-visible-second-clock"; - -const START_MS = Date.UTC(2026, 9, 3, 12, 0, 0); -let root: Root | null = null; -let renders: number[] = []; - -function Probe(props: { readonly enabled: boolean }) { - const nowMs = useVisibleSecondClock(props.enabled); - useEffect(() => { - renders.push(nowMs); - }); - return null; -} - -function mount(enabled: boolean) { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - root = createRoot(document.createElement("div")); - act(() => root!.render()); -} - -// One act per second, so each tick commits its own render as it would on device. -function advance(ms: number) { - for (let elapsed = 0; elapsed < ms; elapsed += 1_000) { - act(() => vi.advanceTimersByTime(Math.min(1_000, ms - elapsed))); - } -} - -function latest() { - return renders.at(-1); -} - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(START_MS); - harness.state.focused = true; - harness.state.appState = "active"; - renders = []; -}); - -afterEach(() => { - act(() => root?.unmount()); - root = null; - vi.useRealTimers(); -}); - -describe("useVisibleSecondClock", () => { - it("ticks once a second while enabled, focused and active", () => { - mount(true); - const before = renders.length; - advance(60_000); - expect(renders.length - before).toBe(60); - expect(latest()).toBe(START_MS + 60_000); - }); - - it("does not wake while the app is in the background", () => { - harness.state.appState = "background"; - mount(true); - const before = renders.length; - advance(60_000); - expect(renders.length - before).toBe(0); - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not wake while the screen is unfocused", () => { - harness.state.focused = false; - mount(true); - const before = renders.length; - advance(60_000); - expect(renders.length - before).toBe(0); - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not wake when disabled, even when visible", () => { - mount(false); - const before = renders.length; - advance(60_000); - expect(renders.length - before).toBe(0); - }); - - it("notices an AppState change between first render and subscribing", () => { - // Layout effects run before the hook's passive subscription, so this change has no listener. - function BackgroundBeforeSubscribe() { - useLayoutEffect(() => harness.setAppState("background"), []); - return null; - } - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - root = createRoot(document.createElement("div")); - act(() => - root!.render( - <> - - - , - ), - ); - const before = renders.length; - advance(60_000); - expect(renders.length - before).toBe(0); - expect(vi.getTimerCount()).toBe(0); - }); - - it("pauses on blur and shows wall-clock elapsed time the moment it refocuses", () => { - mount(true); - advance(5_000); - act(() => harness.setFocused(false)); - const paused = renders.length; - advance(120_000); - expect(renders.length).toBe(paused); - - act(() => harness.setFocused(true)); - expect(latest()).toBe(START_MS + 125_000); - advance(1_000); - expect(latest()).toBe(START_MS + 126_000); - }); - - it("pauses while backgrounded and catches up on the first active render", () => { - mount(true); - act(() => harness.setAppState("background")); - const paused = renders.length; - advance(600_000); - expect(renders.length).toBe(paused); - expect(vi.getTimerCount()).toBe(0); - - act(() => harness.setAppState("inactive")); - advance(10_000); - expect(renders.length).toBe(paused); - - act(() => harness.setAppState("active")); - expect(latest()).toBe(START_MS + 610_000); - const resumed = renders.length; - advance(60_000); - expect(renders.length - resumed).toBe(60); - }); - - it("stops when the work it measures settles and refreshes when new work starts", () => { - mount(true); - advance(3_000); - act(() => root!.render()); - const settled = renders.length; - advance(60_000); - expect(renders.length).toBe(settled); - - act(() => root!.render()); - expect(latest()).toBe(START_MS + 63_000); - }); - - it("clears its interval and AppState subscription on unmount", () => { - mount(true); - expect(vi.getTimerCount()).toBe(1); - expect(harness.appStateListeners.size).toBe(1); - act(() => root!.unmount()); - root = null; - expect(vi.getTimerCount()).toBe(0); - expect(harness.appStateListeners.size).toBe(0); - }); -}); diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx deleted file mode 100644 index 261335b01b98..000000000000 --- a/apps/mobile/src/features/threads/useThreadHeaderOptions.test.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -// Native header item factories are only read when options are applied, so the fake -// navigation snapshots the right items at each setOptions, as native-stack does. -const harness = vi.hoisted(() => ({ - setOptionsCalls: 0, - renderedRightItems: [] as Array>>, - navigatedRoutes: [] as string[], -})); - -vi.mock("@react-navigation/native", () => { - const navigation = { - canGoBack: () => true, - dispatch: () => {}, - goBack: () => {}, - navigate: (route: string) => { - harness.navigatedRoutes.push(route); - }, - addListener: () => () => {}, - setOptions: (options: { unstable_headerRightItems?: () => Array> }) => { - harness.setOptionsCalls += 1; - harness.renderedRightItems.push(options.unstable_headerRightItems?.() ?? []); - }, - }; - return { - StackActions: { replace: () => ({}) }, - useNavigation: () => navigation, - }; -}); -vi.mock("../../native/useOpenPullRequest", () => ({ useOpenPullRequest: () => () => {} })); - -vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); -vi.mock("../../components/ui/AppBar", async () => { - const { NativeStackScreenOptions } = await import("../../native/StackHeader"); - return { - AppBar: (props: import("../../components/ui/AppBar.types").AppBarProps) => ( - - ), - }; -}); -vi.mock("react-native", () => ({ Alert: { alert: () => {} }, Linking: {}, Platform: {} })); -vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({ - useAdaptiveWorkspaceLayout: () => ({ - layout: { usesSplitView: false }, - panes: { primarySidebarVisible: false, auxiliaryPaneVisible: false }, - togglePrimarySidebar: () => {}, - toggleAuxiliaryPane: () => {}, - }), -})); -vi.mock("../layout/native-mail-search-toolbar", () => ({ - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED: true, - createNativeMailSearchToolbarItem: () => ({}), -})); -vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ - useAppearancePreferences: () => ({ themeVariables: {} }), -})); - -import { ThreadHeader } from "./ThreadHeader"; - -const projectScripts: never[] = []; - -function status( - files: ReadonlyArray, - overrides: Partial = {}, -): VcsStatusResult { - return { - isRepo: true, - hasPrimaryRemote: false, - isDefaultRef: true, - refName: "master", - hasWorkingTreeChanges: files.length > 0, - workingTree: { - files: files.map((path) => ({ path, insertions: 0, deletions: 0 })), - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - pr: null, - ...overrides, - } as unknown as VcsStatusResult; -} - -function Header(props: { readonly gitStatus: VcsStatusResult | null; readonly title?: string }) { - return ( - {}, - onRunAction: async () => null, - conductorScripts: [], - onToggleConductorScript: () => {}, - canOpenTerminal: true, - canOperateTerminal: true, - canOpenFiles: true, - projectScripts, - terminalSessions: [], - onOpenTerminal: () => {}, - onOpenNewTerminal: () => {}, - onRunProjectScript: async () => {}, - }} - hasThreadCwd - hasWorkspaceRoot - fileInspectorSupported={false} - inspectorMode={null} - onToggleInspector={() => {}} - onOpenGitInspector={() => {}} - onOpenFilesInspector={() => {}} - /> - ); -} - -function renderedGitMenuItems(): Array<{ - description?: unknown; - label?: unknown; - onPress?: () => void; -}> { - const items = harness.renderedRightItems.at(-1) ?? []; - const git = items.find((item) => item.identifier === "thread-right-git") as - | { menu: { items: Array<{ description?: unknown; label?: unknown; onPress?: () => void }> } } - | undefined; - return git?.menu.items ?? []; -} - -function renderedGitStatusDescription(): unknown { - return renderedGitMenuItems()[0]?.description; -} - -let root: Root | null = null; - -function render(element: React.ReactElement) { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - root ??= createRoot(document.createElement("div")); - act(() => root!.render(element)); -} - -afterEach(() => { - act(() => root?.unmount()); - root = null; - harness.setOptionsCalls = 0; - harness.renderedRightItems = []; - harness.navigatedRoutes = []; -}); - -describe("ThreadHeader", () => { - it("re-applies native header items when Git status changes", () => { - render(
); - expect(renderedGitStatusDescription()).toBe("Clean"); - - render(
); - expect(renderedGitStatusDescription()).toBe("1 changed"); - - render(
); - expect(renderedGitStatusDescription()).toBe("Clean"); - }); - - it("does not re-apply options for re-renders with equivalent header content", () => { - render(
); - const applied = harness.setOptionsCalls; - - for (let index = 0; index < 20; index += 1) { - render(
); - } - expect(harness.setOptionsCalls).toBe(applied); - }); - - it("re-applies menu actions when the default ref changes behind the same label", async () => { - const pushable = (isDefaultRef: boolean) => - status([], { - isDefaultRef, - aheadCount: 1, - hasPrimaryRemote: true, - pr: { state: "open", url: "https://example.com/pr/1" }, - } as Partial); - render(
); - expect(renderedGitMenuItems()[1]?.label).toBe("Push"); - - render(
); - expect(renderedGitMenuItems()[1]?.label).toBe("Push"); - await act(async () => renderedGitMenuItems()[1]?.onPress?.()); - expect(harness.navigatedRoutes).toEqual(["GitConfirm"]); - }); -}); diff --git a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts b/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts deleted file mode 100644 index f498838fbdc2..000000000000 --- a/apps/mobile/src/features/threads/userMessageIntentBadge.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; - -describe("user message intent badge", () => { - it("does not label turn-start or queued messages", () => { - expect(resolveUserMessageIntentBadge(undefined)).toBeNull(); - expect(resolveUserMessageIntentBadge("turn_start")).toBeNull(); - expect(resolveUserMessageIntentBadge("queued_turn")).toBeNull(); - }); - - it("explains the intent to steer without claiming delivery", () => { - expect(resolveUserMessageIntentBadge("steer")).toEqual({ - label: "Steer", - accessibilityLabel: "Steered the active turn", - }); - }); - - it("preserves the queued origin after promotion to steer", () => { - expect(resolveUserMessageIntentBadge("promoted_queued_to_steer")).toEqual({ - label: "Steer", - accessibilityLabel: "Originally queued, then promoted to steer the active turn", - }); - }); -}); diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index e35f173a4729..73c95d01d8eb 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -3,10 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateDeferral, createAppUpdateLaunchCheck, - FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, - registerHiddenUpdateTap, runAppUpdateCheck, - shouldRecheckAppUpdateOnForeground, type AppUpdateCheckState, type AppUpdateClient, type AppUpdateEnvironment, @@ -19,6 +16,13 @@ vi.mock("expo-updates", () => ({ reloadAsync: vi.fn(), })); +vi.mock("react-native", () => ({ + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})); + function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { return { isEnabled: true, @@ -71,45 +75,6 @@ function makeAvailableUpdateClient(overrides: Partial = {}): Ap } describe("runAppUpdateCheck", () => { - it("does nothing while running from the Metro development server", async () => { - vi.stubGlobal("__DEV__", true); - const client = makeUpdateClient(); - - try { - await runAppUpdateCheck({ client }); - } finally { - vi.unstubAllGlobals(); - } - - expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); - }); - - it("downloads silently and installs at the next backgrounding", async () => { - const client = makeAvailableUpdateClient(); - const { backgroundCallbacks, environment } = makeUpdateEnvironment(); - const deferral = createAppUpdateDeferral(); - const states: AppUpdateCheckState[] = []; - - await runAppUpdateCheck({ - client, - deferral, - environment, - onStateChange: (state) => states.push(state), - }); - - expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); - expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); - expect(environment.confirmInstallNow).not.toHaveBeenCalled(); - expect(client.reloadAsync).not.toHaveBeenCalled(); - expect(states).toEqual(["checking", "downloading", "ready"]); - expect(deferral.pendingInstall).toBe(true); - expect(backgroundCallbacks).toHaveLength(1); - - backgroundCallbacks[0]!(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - expect(environment.flushPendingWrites).toHaveBeenCalled(); - }); - it("flushes pending writes before restarting", async () => { const client = makeAvailableUpdateClient(); const { environment } = makeUpdateEnvironment(); @@ -126,105 +91,6 @@ describe("runAppUpdateCheck", () => { expect(flushOrder).toBeLessThan(reloadOrder); }); - it("prompts once the app has stayed foregrounded with the download waiting", async () => { - const client = makeAvailableUpdateClient(); - const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - expect(environment.confirmInstallNow).not.toHaveBeenCalled(); - expect(foregroundStayCallbacks).toHaveLength(1); - - foregroundStayCallbacks[0]!(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - expect(environment.confirmInstallNow).toHaveBeenCalledOnce(); - expect(environment.flushPendingWrites).toHaveBeenCalled(); - }); - - it("keeps the background install armed when the foreground prompt is declined", async () => { - const client = makeAvailableUpdateClient(); - const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ - confirmInstallNow: vi.fn(async () => false), - }); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - - foregroundStayCallbacks[0]!(); - await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce()); - expect(client.reloadAsync).not.toHaveBeenCalled(); - expect(deferral.pendingInstall).toBe(true); - - backgroundCallbacks[0]!(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - }); - - it("skips the foreground prompt once the install is no longer pending", async () => { - const client = makeAvailableUpdateClient(); - const { environment, foregroundStayCallbacks } = makeUpdateEnvironment(); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - - deferral.pendingInstall = false; - foregroundStayCallbacks[0]!(); - - expect(environment.confirmInstallNow).not.toHaveBeenCalled(); - expect(client.reloadAsync).not.toHaveBeenCalled(); - }); - - it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => { - const client = makeAvailableUpdateClient(); - const safe = vi.fn(async () => false); - const { backgroundCallbacks, environment } = makeUpdateEnvironment({ - isSafeToRestartInBackground: safe, - }); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - expect(backgroundCallbacks).toHaveLength(1); - expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true); - - backgroundCallbacks[0]!(); - await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2)); - expect(client.reloadAsync).not.toHaveBeenCalled(); - expect(deferral.pendingInstall).toBe(true); - expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false); - - safe.mockResolvedValue(true); - backgroundCallbacks[1]!(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - }); - - it("resets the deferral when the deferred restart fails", async () => { - const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); - const client = makeAvailableUpdateClient({ - reloadAsync: vi.fn(async () => { - throw new Error("reload rejected"); - }), - }); - const { backgroundCallbacks, environment } = makeUpdateEnvironment(); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - backgroundCallbacks[0]!(); - - await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false)); - reportError.mockRestore(); - }); - - it("arms the deferred install once across repeated checks", async () => { - const client = makeAvailableUpdateClient(); - const { environment } = makeUpdateEnvironment(); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - await runAppUpdateCheck({ client, deferral, environment }); - - expect(environment.onNextBackground).toHaveBeenCalledOnce(); - expect(environment.onForegroundStay).toHaveBeenCalledOnce(); - }); - it("restarts into an already-downloaded update when the user asks to install", async () => { const client = makeUpdateClient(); const { environment } = makeUpdateEnvironment(); @@ -269,31 +135,6 @@ describe("runAppUpdateCheck", () => { expect(client.reloadAsync).toHaveBeenCalledOnce(); }); - it("runs a single restart when the deferred install races the foreground prompt", async () => { - const client = makeAvailableUpdateClient(); - let releaseFlush!: () => void; - const blockedFlush = new Promise((resolve) => { - releaseFlush = resolve; - }); - const flushPendingWrites = vi.fn(async (): Promise => {}); - const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({ - flushPendingWrites, - }); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - flushPendingWrites.mockReturnValue(blockedFlush); - - backgroundCallbacks[0]!(); - await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce()); - foregroundStayCallbacks[0]!(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(environment.confirmInstallNow).not.toHaveBeenCalled(); - - releaseFlush(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - }); - it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => { const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); const client = makeAvailableUpdateClient(); @@ -313,54 +154,6 @@ describe("runAppUpdateCheck", () => { reportError.mockRestore(); }); - it("restarts without prompting when the caller asked for an immediate install", async () => { - const client = makeAvailableUpdateClient(); - const { environment } = makeUpdateEnvironment(); - const states: AppUpdateCheckState[] = []; - - await runAppUpdateCheck({ - applyMode: "immediate", - client, - deferral: createAppUpdateDeferral(), - environment, - onStateChange: (state) => states.push(state), - }); - - expect(environment.confirmInstallNow).not.toHaveBeenCalled(); - expect(client.reloadAsync).toHaveBeenCalledOnce(); - expect(states).toEqual(["checking", "downloading", "restarting"]); - }); - - it("holds an automatic rollback restart when the flush fails and re-arms it", async () => { - const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); - const client = makeUpdateClient({ - checkForUpdateAsync: vi.fn(async () => ({ - isAvailable: false, - isRollBackToEmbedded: true, - })), - fetchUpdateAsync: vi.fn(async () => ({ - isNew: false, - isRollBackToEmbedded: true, - })), - }); - const flushPendingWrites = vi.fn(async (): Promise => { - throw new Error("storage unavailable"); - }); - const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites }); - const deferral = createAppUpdateDeferral(); - - await runAppUpdateCheck({ client, deferral, environment }); - - expect(client.reloadAsync).not.toHaveBeenCalled(); - expect(deferral.pendingInstall).toBe(true); - expect(backgroundCallbacks).toHaveLength(1); - - flushPendingWrites.mockResolvedValue(undefined); - backgroundCallbacks[0]!(); - await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce()); - reportError.mockRestore(); - }); - it("still restarts a user-requested install when the flush fails", async () => { const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); const client = makeAvailableUpdateClient(); @@ -401,39 +194,6 @@ describe("runAppUpdateCheck", () => { expect(client.reloadAsync).toHaveBeenCalledOnce(); }); - it("stops quietly when the running bundle is current", async () => { - const client = makeUpdateClient(); - const states: AppUpdateCheckState[] = []; - - await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); - - expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); - expect(client.reloadAsync).not.toHaveBeenCalled(); - expect(states).toEqual(["checking", "current"]); - }); - - it("reports manual failures without continuing the update", async () => { - const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); - const client = makeUpdateClient({ - checkForUpdateAsync: vi.fn(async () => { - throw new Error("offline"); - }), - }); - const failures: string[] = []; - const states: AppUpdateCheckState[] = []; - - await runAppUpdateCheck({ - client, - onFailure: (message) => failures.push(message), - onStateChange: (state) => states.push(state), - }); - - expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); - expect(failures).toEqual(["offline"]); - expect(states).toEqual(["checking", "idle"]); - reportError.mockRestore(); - }); - it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( "treats Expo's %s failure as an unavailable update check", async (code) => { @@ -498,38 +258,6 @@ describe("runAppUpdateCheck", () => { expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2); }); - it("forwards failures to a manual check coalesced with the launch check", async () => { - const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); - let rejectCheck!: (error: Error) => void; - const checkResult = new Promise<{ - readonly isAvailable: boolean; - readonly isRollBackToEmbedded: boolean; - }>((_resolve, reject) => { - rejectCheck = reject; - }); - const client = makeUpdateClient({ - checkForUpdateAsync: vi.fn(() => checkResult), - }); - const checkOnLaunch = createAppUpdateLaunchCheck(client); - const failures: string[] = []; - const manualStates: AppUpdateCheckState[] = []; - - const launchCheck = checkOnLaunch(); - const manualCheck = runAppUpdateCheck({ - client, - onFailure: (message) => failures.push(message), - onStateChange: (state) => manualStates.push(state), - }); - - rejectCheck(new Error("offline")); - await Promise.all([launchCheck, manualCheck]); - - expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); - expect(failures).toEqual(["offline"]); - expect(manualStates).toEqual(["checking", "idle"]); - reportError.mockRestore(); - }); - it("publishes the in-flight check before a state callback can re-enter", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; @@ -587,56 +315,4 @@ describe("createAppUpdateLaunchCheck", () => { expect(second).toBeUndefined(); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); }); - - it("does nothing when Expo updates are disabled", () => { - const client = makeUpdateClient({ isEnabled: false }); - const checkOnLaunch = createAppUpdateLaunchCheck(client); - - expect(checkOnLaunch()).toBeUndefined(); - expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); - }); -}); - -describe("shouldRecheckAppUpdateOnForeground", () => { - it("requires a meaningful background gap", () => { - expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false); - expect( - shouldRecheckAppUpdateOnForeground( - 100_000, - 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1, - false, - ), - ).toBe(false); - expect( - shouldRecheckAppUpdateOnForeground( - 100_000, - 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, - false, - ), - ).toBe(true); - }); - - it("stays quiet while a downloaded update waits for its install", () => { - expect( - shouldRecheckAppUpdateOnForeground( - 100_000, - 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS, - true, - ), - ).toBe(false); - }); -}); - -describe("registerHiddenUpdateTap", () => { - it("unlocks the manual check on the fifth tap", () => { - let count = 0; - - for (let tap = 1; tap <= 5; tap += 1) { - const result = registerHiddenUpdateTap(count); - expect(result.shouldCheck).toBe(tap === 5); - count = result.nextCount; - } - - expect(count).toBe(0); - }); }); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 2cfe3aa7aebf..6390256108ec 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -1,4 +1,6 @@ import * as Updates from "expo-updates"; +// Metro namespace imports evaluate unrelated React Native getters, including unavailable native modules. +import { AppState } from "react-native"; import { type AtomCommandResult, @@ -418,22 +420,19 @@ async function defaultFlushPendingWrites(): Promise { async function defaultIsSafeToRestartInBackground(): Promise { const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff"); if (isForegroundHandoffActive()) return false; - const { AppState } = await import("react-native"); return AppState.currentState === "background"; } function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void { - void import("react-native").then(({ AppState }) => { - const subscription = AppState.addEventListener("change", (state) => { - if (state !== "background") return; - subscription.remove(); - apply(); - }); - if (includeCurrent && AppState.currentState === "background") { - subscription.remove(); - apply(); - } + const subscription = AppState.addEventListener("change", (state) => { + if (state !== "background") return; + subscription.remove(); + apply(); }); + if (includeCurrent && AppState.currentState === "background") { + subscription.remove(); + apply(); + } } export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; @@ -443,25 +442,23 @@ export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000; * the timer running. */ function defaultOnForegroundStay(apply: () => void): void { - void import("react-native").then(({ AppState }) => { - let timer: ReturnType | undefined; - const arm = () => { - timer ??= setTimeout(() => { - subscription.remove(); - apply(); - }, DEFERRED_INSTALL_PROMPT_AFTER_MS); - }; - const disarm = () => { - if (timer === undefined) return; - clearTimeout(timer); - timer = undefined; - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") arm(); - else if (state === "background") disarm(); - }); - if (AppState.currentState === "active") arm(); + let timer: ReturnType | undefined; + const arm = () => { + timer ??= setTimeout(() => { + subscription.remove(); + apply(); + }, DEFERRED_INSTALL_PROMPT_AFTER_MS); + }; + const disarm = () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") arm(); + else if (state === "background") disarm(); }); + if (AppState.currentState === "active") arm(); } const defaultAppUpdateEnvironment: AppUpdateEnvironment = { @@ -528,22 +525,20 @@ export function createAppUpdateForegroundRecheck( return () => { if (started || !isAppUpdateCheckAvailable(client)) return; started = true; - void import("react-native").then(({ AppState }) => { - let backgroundedAtMs: number | null = null; - AppState.addEventListener("change", (state) => { - if (state === "background") { - backgroundedAtMs = Date.now(); - return; - } - if (state !== "active") return; - const shouldCheck = shouldRecheckAppUpdateOnForeground( - backgroundedAtMs, - Date.now(), - deferral.pendingInstall, - ); - backgroundedAtMs = null; - if (shouldCheck) void runAppUpdateCheck({ client, deferral }); - }); + let backgroundedAtMs: number | null = null; + AppState.addEventListener("change", (state) => { + if (state === "background") { + backgroundedAtMs = Date.now(); + return; + } + if (state !== "active") return; + const shouldCheck = shouldRecheckAppUpdateOnForeground( + backgroundedAtMs, + Date.now(), + deferral.pendingInstall, + ); + backgroundedAtMs = null; + if (shouldCheck) void runAppUpdateCheck({ client, deferral }); }); }; } diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 861419c78bf3..195dcb93d352 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -42,6 +42,53 @@ function accountName(account: LimitAccount) { return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; } +// Same gap-and-tick as the Limits rows: two clipped copies of the segment leave a see-through gap. +function PaceGap({ + timeLeft, + children, +}: { + readonly timeLeft: number | null; + readonly children: ReactNode; +}) { + const [width, setWidth] = useState(0); + const tickX = + timeLeft === null || width < 6 + ? null + : Math.min(Math.max((width * timeLeft) / 100, 3), width - 3); + return ( + setWidth(event.nativeEvent.layout.width)} + > + {tickX === null + ? children + : ( + [ + [0, tickX - 3], + [tickX + 3, width], + ] as const + ).map(([start, end]) => ( + + + {children} + + + ))} + {tickX !== null ? ( + + ) : null} + + ); +} + function AccountSegment({ remaining, color, @@ -156,22 +203,18 @@ function PoolWindowCard({ accessibilityLabel={`Segment ${index + 1}, ${accountName(account)}, ${remainingPercent(window)}% left${timeLeft === null ? "" : `, ${timeLeft}% of the window left`}`} accessibilityHint="Show account details" onPress={() => openAccount(account)} - className="h-7 min-w-0 flex-1 rounded-md bg-subtle" + className="h-7 min-w-0 flex-1 rounded-md" > - - - - {timeLeft !== null ? ( - - ) : null} + + + + + + 0 ? { width: barWidth } : undefined} + > + 30 && props.color ? { backgroundColor: props.color } : null, + ]} + /> + + + ); return ( @@ -57,27 +83,35 @@ function WindowRow(props: { {remaining}% left - - - 30 && props.color ? { backgroundColor: props.color } : null, - ]} - /> - - - {timeLeft !== null ? ( + setBarWidth(event.nativeEvent.layout.width)} + > + {tickX === null ? ( + bar + ) : ( + + {/* Clip the same full-width bar so neither fill proportions nor corner radii change. */} + {( + [ + [0, tickX - 3], + [tickX + 3, barWidth], + ] as const + ).map(([start, end]) => ( + + {bar} + + ))} + + )} + {tickX !== null ? ( ) : null} diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts index 4914f9de0f8b..2492bbbed938 100644 --- a/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts @@ -16,10 +16,6 @@ describe("usage environment selection", () => { expect(toggleUsageEnvironment(selected, environments, b)).toBeNull(); }); - it("can deselect the last environment", () => { - expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set()); - }); - it("does not count removed IDs toward selecting all current environments", () => { expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b])); }); @@ -28,10 +24,6 @@ describe("usage environment selection", () => { expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull(); }); - it("ignores a menu action for an environment that was removed", () => { - expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a])); - }); - it("includes newly connected environments only in all mode", () => { const expanded = [...environments, { environmentId: removed }]; expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed])); diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts index d0e3832fd94d..d3236bc7c992 100644 --- a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts +++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts @@ -7,14 +7,6 @@ import { createLazyVoiceRecorder, type VoiceRecordingInput } from "./lazyVoiceRe type Status = { readonly isFinished: boolean }; type State = { readonly isRecording: boolean }; -function deferred() { - let resolve!: () => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - function createNativeRecorder(uri: string) { const listeners = new Set<(status: Status) => void>(); return { @@ -58,45 +50,6 @@ function createHarness( } describe("createLazyVoiceRecorder", () => { - it("does not create a native recorder until a recording prepares", async () => { - const { recorder, created } = createHarness(); - - expect(recorder.uri).toBeNull(); - expect(recorder.getStatus()).toBeNull(); - await recorder.stop(); - recorder.release(); - expect(created).toHaveLength(0); - - await recorder.prepareToRecordAsync(); - recorder.record({ forDuration: 300 }); - - expect(created).toHaveLength(1); - expect(created[0]!.record).toHaveBeenCalledWith({ forDuration: 300 }); - expect(recorder.uri).toBe("file:///voice-0.m4a"); - expect(recorder.getStatus()).toEqual({ isRecording: true }); - }); - - it("reuses one native recorder across recordings", async () => { - const { recorder, created } = createHarness(); - - await recorder.prepareToRecordAsync(); - await recorder.stop(); - await recorder.prepareToRecordAsync(); - - expect(created).toHaveLength(1); - expect(created[0]!.prepareToRecordAsync).toHaveBeenCalledTimes(2); - }); - - it("selects the preferred input after preparing, when the session lists inputs", async () => { - const { recorder, created } = createHarness( - (inputs) => inputs.find((input) => input.type === "MicrophoneBuiltIn") ?? null, - ); - - await recorder.prepareToRecordAsync(); - - expect(created[0]!.setInput).toHaveBeenCalledWith("built-in"); - }); - it("keeps the system input when none is preferred or the preferred one disconnects", async () => { const unpreferred = createHarness(() => null); await unpreferred.recorder.prepareToRecordAsync(); @@ -122,42 +75,6 @@ describe("createLazyVoiceRecorder", () => { expect(statuses).toEqual([{ isFinished: true }]); }); - it("releases only after a pending stop settles and keeps the final uri", async () => { - const { recorder, created } = createHarness(); - await recorder.prepareToRecordAsync(); - const native = created[0]!; - const stopped = deferred(); - native.stop.mockReturnValueOnce(stopped.promise); - - const stopping = recorder.stop(); - recorder.release(); - await Promise.resolve(); - expect(native.release).not.toHaveBeenCalled(); - expect(recorder.uri).toBe("file:///voice-0.m4a"); - - stopped.resolve(); - await stopping; - await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1)); - expect(recorder.uri).toBe("file:///voice-0.m4a"); - expect(recorder.getStatus()).toBeNull(); - }); - - it("releases after a failed stop and creates a fresh recorder for the next start", async () => { - const { recorder, created } = createHarness(); - await recorder.prepareToRecordAsync(); - const first = created[0]!; - first.stop.mockRejectedValueOnce(new Error("not recording")); - - await expect(recorder.stop()).rejects.toThrow("not recording"); - recorder.release(); - await vi.waitFor(() => expect(first.release).toHaveBeenCalledTimes(1)); - expect(() => recorder.record({ forDuration: 1 })).toThrow("not prepared"); - - await recorder.prepareToRecordAsync(); - expect(created).toHaveLength(2); - expect(recorder.uri).toBe("file:///voice-1.m4a"); - }); - describe("with the voice input controller", () => { function createController(granted: boolean) { const harness = createHarness(); @@ -191,22 +108,5 @@ describe("createLazyVoiceRecorder", () => { expect(controller.currentState.phase).toBe("error"); expect(created).toHaveLength(0); }); - - it("stops, deletes, and releases the recorder created by a cancelled recording", async () => { - const { controller, recorder, created, deleted } = createController(true); - - await controller.start(); - expect(controller.currentState.phase).toBe("recording"); - expect(created).toHaveLength(1); - const native = created[0]!; - - controller.cancel(); - recorder.release(); - await vi.waitFor(() => expect(native.release).toHaveBeenCalledTimes(1)); - - expect(native.stop).toHaveBeenCalledTimes(1); - expect(deleted).toEqual(["file:///voice-0.m4a"]); - expect(controller.currentState.phase).toBe("idle"); - }); }); }); diff --git a/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts b/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts deleted file mode 100644 index 05356eaeeab9..000000000000 --- a/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { normalizeVoiceInputDecibels } from "./voiceInputMetering"; - -describe("normalizeVoiceInputDecibels", () => { - it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( - "treats a missing or invalid reading %s as silence", - (decibels) => { - expect(normalizeVoiceInputDecibels(decibels)).toBe(0); - }, - ); - - it.each([-160, -90, -60])("keeps a reading at or below the noise floor %s silent", (decibels) => { - expect(normalizeVoiceInputDecibels(decibels)).toBe(0); - }); - - it("keeps quiet background readings close to the baseline", () => { - const quiet = normalizeVoiceInputDecibels(-50); - expect(quiet).toBeGreaterThan(0); - expect(quiet).toBeLessThan(0.05); - }); - - it("keeps loud negative speech readings distinct below full height", () => { - const levels = [-20, -18, -12, -6, -3].map(normalizeVoiceInputDecibels); - - for (const level of levels) { - expect(level).toBeGreaterThan(0); - expect(level).toBeLessThan(1); - } - expect(levels.every((level, index) => index === 0 || level > levels[index - 1]!)).toBe(true); - }); - - it("makes near-speech changes visible without an early ceiling", () => { - expect(normalizeVoiceInputDecibels(-6) - normalizeVoiceInputDecibels(-12)).toBeGreaterThan( - 0.18, - ); - expect(normalizeVoiceInputDecibels(-3) - normalizeVoiceInputDecibels(-12)).toBeGreaterThan(0.3); - }); - - it("increases throughout the usable microphone range", () => { - const levels = [-60, -55, -50, -40, -30, -20, -12, -6, -3, -0.001, 0].map( - normalizeVoiceInputDecibels, - ); - expect(levels.every((level, index) => index === 0 || level > levels[index - 1]!)).toBe(true); - }); - - it("approaches the noise floor and full scale without a jump", () => { - expect(normalizeVoiceInputDecibels(-59.999)).toBeLessThan(0.001); - expect(normalizeVoiceInputDecibels(-0.001)).toBeGreaterThan(0.999); - expect(normalizeVoiceInputDecibels(-0.001)).toBeLessThan(1); - }); - - it.each([0, 6, 160])("caps only full-scale or higher readings %s at one", (decibels) => { - expect(normalizeVoiceInputDecibels(decibels)).toBe(1); - }); -}); diff --git a/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts b/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts deleted file mode 100644 index caf160937102..000000000000 --- a/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { voiceInputFreezesEditor } from "@t3tools/client-runtime/voice-input"; - -import { resolveVoiceComposerPresentation } from "./voiceInputPresentation"; - -describe("resolveVoiceComposerPresentation", () => { - it("maps voice states to stable composer actions and editor read-only state", () => { - expect( - resolveVoiceComposerPresentation({ phase: "idle", error: null, errorAction: null }, 0), - ).toEqual({ - leadingAction: null, - trailingAction: "mic", - showsSend: true, - statusKind: null, - statusLabel: null, - confirmationEnabled: false, - }); - expect( - resolveVoiceComposerPresentation({ phase: "preparing", error: null, errorAction: null }, 0), - ).toMatchObject({ - leadingAction: "cancel", - trailingAction: "confirm", - showsSend: false, - statusLabel: "Preparing", - confirmationEnabled: false, - }); - expect( - resolveVoiceComposerPresentation({ phase: "recording", error: null, errorAction: null }, 64), - ).toMatchObject({ - leadingAction: "cancel", - trailingAction: "confirm", - showsSend: false, - statusLabel: "Recording 1:04", - confirmationEnabled: true, - }); - expect( - resolveVoiceComposerPresentation( - { phase: "transcribing", error: null, errorAction: null }, - 0, - ), - ).toMatchObject({ - statusLabel: "Transcribing", - confirmationEnabled: false, - }); - expect( - resolveVoiceComposerPresentation( - { phase: "error", error: "Microphone unavailable", errorAction: "retry" }, - 0, - ), - ).toMatchObject({ - leadingAction: null, - trailingAction: "mic", - showsSend: true, - statusKind: "error", - statusLabel: "Microphone unavailable", - }); - - expect(voiceInputFreezesEditor({ phase: "preparing", error: null, errorAction: null })).toBe( - true, - ); - expect(voiceInputFreezesEditor({ phase: "recording", error: null, errorAction: null })).toBe( - true, - ); - expect(voiceInputFreezesEditor({ phase: "transcribing", error: null, errorAction: null })).toBe( - true, - ); - expect(voiceInputFreezesEditor({ phase: "idle", error: null, errorAction: null })).toBe(false); - }); -}); diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts index 3e15c905d686..b57aa0916c68 100644 --- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts +++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts @@ -214,22 +214,6 @@ describe("global voice input", () => { expect(commit).not.toHaveBeenCalled(); }); - it.each(["complete", "cancel"] as const)( - "releases draft observation after %s", - async (finish) => { - const { session } = createSession(); - const unsubscribe = vi.fn(); - const subscribe = vi.fn(() => unsubscribe); - await session.start( - createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }, subscribe), - ); - expect(subscribe).toHaveBeenCalledTimes(1); - if (finish === "complete") await session.controller.stop(); - else session.cancel("first"); - expect(unsubscribe).toHaveBeenCalledTimes(1); - }, - ); - it("keeps another prompt's recording when a queued edit is discarded", async () => { const { session, recorder } = createSession(); const commit = vi.fn(); diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts index efdf234e6c9d..e8f29a9cf8b6 100644 --- a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts @@ -48,7 +48,7 @@ describe("Android workspace navigation colors", () => { }, ); - it.each(["#6750a4", "#006a6a", "#9c4146"])( + it.each(["#6750a4", "#9c4146"])( "keeps Material You selection visible for system seed %s", (seed) => { for (const appearance of ["light", "dark"] as const) { @@ -96,14 +96,4 @@ describe("Android workspace navigation colors", () => { } }, ); - - it("preserves an already visible tonal fill", () => { - const variables = getMobileThemeRuntimeVariables("t3-chat", "light", "android"); - const colors = androidWorkspaceNavigationColors( - { ...variables, "--color-secondary": "#e091cf" }, - true, - ); - expect(colors.indicatorColor).toBe("#e091cf"); - expectVisibleSelection(colors); - }); }); diff --git a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts index 275b33f4d4d4..22f488faf703 100644 --- a/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts +++ b/apps/mobile/src/features/workspace/workspace-tabs-state.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { - initialWorkspaceTabsState, - workspaceTabsReducer, - workspaceToolbarHeight, -} from "./workspace-tabs-state"; +import { initialWorkspaceTabsState, workspaceTabsReducer } from "./workspace-tabs-state"; describe("workspace root navigation", () => { it("retains separate queries and positions across tabs, and cancels only the current search", () => { @@ -53,12 +49,6 @@ describe("workspace root navigation", () => { expect(workspaceTabsReducer(state, { type: "cancel" }).tabs.threads.scrollOffset).toBe(500); }); - it("reserves space for both Android toolbar rows when expanded", () => { - expect(workspaceToolbarHeight("expanded")).toBeGreaterThan( - workspaceToolbarHeight("resting") * 2, - ); - }); - it("a late native blur cannot reopen a cancelled search", () => { let state = workspaceTabsReducer(initialWorkspaceTabsState(), { type: "search" }); state = workspaceTabsReducer(state, { type: "cancel" }); diff --git a/apps/mobile/src/lib/adaptive-navigation.test.ts b/apps/mobile/src/lib/adaptive-navigation.test.ts index efae0024c454..2e6d78cf35b3 100644 --- a/apps/mobile/src/lib/adaptive-navigation.test.ts +++ b/apps/mobile/src/lib/adaptive-navigation.test.ts @@ -1,31 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { - isBaseThreadRoute, - resolveFileSelectionNavigationAction, resolveThreadSelectionNavigationAction, resolveThreadSelectionOverlayState, } from "./adaptive-navigation"; -describe("isBaseThreadRoute", () => { - it("recognizes only the thread detail route", () => { - expect(isBaseThreadRoute("/threads/environment/thread")).toBe(true); - expect(isBaseThreadRoute("/threads/environment/thread/")).toBe(true); - expect(isBaseThreadRoute("/threads/environment/thread/files")).toBe(false); - expect(isBaseThreadRoute("/threads/environment/thread/review")).toBe(false); - }); -}); - describe("resolveThreadSelectionNavigationAction", () => { - it("updates params when a persistent sidebar selects a peer thread", () => { - expect( - resolveThreadSelectionNavigationAction({ - usesSplitView: true, - pathname: "/threads/environment/thread", - }), - ).toBe("set-params"); - }); - it("replaces nested thread content when a persistent sidebar selects a peer", () => { expect( resolveThreadSelectionNavigationAction({ @@ -43,29 +23,6 @@ describe("resolveThreadSelectionNavigationAction", () => { }), ).toBe("push"); }); - - it("pushes compact list selections onto the native stack", () => { - expect( - resolveThreadSelectionNavigationAction({ - usesSplitView: false, - pathname: "/threads/environment/thread", - }), - ).toBe("push"); - }); -}); - -describe("resolveFileSelectionNavigationAction", () => { - it("replaces the wide file browser with the selected preview", () => { - expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: true })).toBe( - "replace", - ); - }); - - it("pushes a preview above the compact file browser", () => { - expect(resolveFileSelectionNavigationAction({ hasPersistentFileInspector: false })).toBe( - "push", - ); - }); }); describe("resolveThreadSelectionOverlayState", () => { @@ -124,15 +81,4 @@ describe("resolveThreadSelectionOverlayState", () => { ).toEqual({ ...stack, index: 1, routes: [route, { name: "Thread", params }] }); }, ); - - it("leaves ordinary thread selection alone when no overlay is present", () => { - expect( - resolveThreadSelectionOverlayState({ - state: { ...stack, index: 2, routes: [home, thread, files] }, - workspaceRouteKey: files.key, - action: "replace", - params, - }), - ).toBeNull(); - }); }); diff --git a/apps/mobile/src/lib/android-workspace-navigation.test.ts b/apps/mobile/src/lib/android-workspace-navigation.test.ts index 63f9759948f3..ac163d772149 100644 --- a/apps/mobile/src/lib/android-workspace-navigation.test.ts +++ b/apps/mobile/src/lib/android-workspace-navigation.test.ts @@ -110,18 +110,6 @@ describe("Android workspace navigation", () => { expect(names(apply(second, StackActions.pop()))).toEqual(["Home"]); }); - it("dismisses nested content and overlays when the sidebar selects a peer", () => { - let state = legacyHistory(); - state = apply( - state, - StackActions.push("PullRequestFile", { ...pullRequest.params, path: "src/main.ts" }), - ); - state = apply(state, StackActions.push("SettingsSheet")); - const selected = select(state, thread); - expect(names(selected)).toEqual(["Home", "Thread"]); - expect(selected.routes[1]?.params).toEqual(thread.params); - }); - it("creates Home beneath cold-linked details and gives direct list escape", () => { const initial = router.getInitialState(options); const cold = apply(initial, CommonActions.reset({ index: 0, routes: [pullRequest] })); @@ -137,17 +125,6 @@ describe("Android workspace navigation", () => { expect(back.routes[back.index]?.params).toEqual(pullRequest.params); }); - it("preserves normal nested PR Back behavior", () => { - const detail = select(legacyHistory(), pullRequest); - const nested = apply( - detail, - StackActions.push("PullRequestFile", { ...pullRequest.params, path: "file.ts" }), - ); - const back = apply(nested, StackActions.pop()); - expect(back.routes[back.index]?.key).toBe(detail.routes[detail.index]?.key); - expect(names(back)).toEqual(["Home", "PullRequest"]); - }); - it.each(["ThreadFiles", "ThreadTerminal", "GitOverview"])( "returns to chat when its sidebar row is selected from %s", (route) => { @@ -160,30 +137,18 @@ describe("Android workspace navigation", () => { }, ); - it.each([ - { environmentId: "environment-b" }, - { projectId: "project-b" }, - { repository: "owner/other" }, - { host: "git.example.test" }, - { number: 43 }, - ])("does not reuse another PR's key when its scope differs: %o", (change) => { - const previous = select(router.getInitialState(options), pullRequest); - const state = select(previous, { - name: "PullRequest", - params: { ...pullRequest.params, ...change }, - }); - expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); - expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change }); - }); - - it("does not reuse a thread key from another environment", () => { - const previous = select(router.getInitialState(options), thread); - const state = select(previous, { - name: "Thread", - params: { ...thread.params, environmentId: "environment-b" }, - }); - expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); - }); + it.each([{ environmentId: "environment-b" }, { repository: "owner/other" }])( + "does not reuse another PR's key when its scope differs: %o", + (change) => { + const previous = select(router.getInitialState(options), pullRequest); + const state = select(previous, { + name: "PullRequest", + params: { ...pullRequest.params, ...change }, + }); + expect(state.routes[1]?.key).not.toBe(previous.routes[1]?.key); + expect(state.routes[1]?.params).toEqual({ ...pullRequest.params, ...change }); + }, + ); it("returns to a distinct previous thread, otherwise to the workspace list", () => { const parent = select(router.getInitialState(options), thread); @@ -258,14 +223,6 @@ describe("Android workspace navigation", () => { ).toBe(false); }); - it("drops stale detail history on a tab switch while retaining Home", () => { - const previous = legacyHistory(); - const state = select(previous); - expect(names(state)).toEqual(["Home"]); - expect(state.routes[0]?.key).toBe(previous.routes[0]?.key); - expect(state.index).toBe(0); - }); - it("resets the root stack when a nested Settings stack has focus", () => { const root = apply(router.getInitialState(options), StackActions.push("SettingsSheet")); const settings = router.getInitialState({ diff --git a/apps/mobile/src/lib/androidControlSizing.test.ts b/apps/mobile/src/lib/androidControlSizing.test.ts deleted file mode 100644 index 8b9fd9751cbb..000000000000 --- a/apps/mobile/src/lib/androidControlSizing.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveAndroidControlSizing } from "./androidControlSizing"; - -describe("Android control sizing", () => { - it.each([ - [11, 17, 48, 48, 172, 48], - [16, 24, 48, 56, 250, 48], - [22, 33, 66, 77, 344, 66], - ])( - "scales controls at %ipt", - (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => { - expect(resolveAndroidControlSizing(fontSize)).toMatchObject({ - iconSize, - buttonSize, - fabSize, - menuWidth, - menuItemHeight, - }); - }, - ); -}); diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 1986a3266fa9..1bfedc7529c7 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -1,137 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { - DEFAULT_BASE_FONT_SIZE, - normalizeBaseFontSize, - resolveAppearance, - resolveAppearancePreferences, - resolveMarkdownFontSizes, - resolveMobileCodeSurface, - resolveNativeMarkdownTypography, - resolveTextScaleVariables, - stepTerminalFontSize, -} from "./appearancePreferences"; +import { resolveAppearancePreferences } from "./appearancePreferences"; describe("appearancePreferences", () => { - it("resolves defaults for empty stored preferences", () => { - expect(resolveAppearancePreferences({})).toEqual({ - baseFontSize: DEFAULT_BASE_FONT_SIZE, - terminalFontSize: null, - codeFontSize: null, - codeWordBreak: false, - }); - }); - it("migrates the legacy markdownFontSize key to baseFontSize", () => { expect(resolveAppearancePreferences({ markdownFontSize: 18 }).baseFontSize).toBe(18); expect( resolveAppearancePreferences({ baseFontSize: 16, markdownFontSize: 18 }).baseFontSize, ).toBe(16); }); - - it("keeps explicit overrides and treats missing values as automatic", () => { - const preferences = resolveAppearancePreferences({ terminalFontSize: 12, codeFontSize: 14 }); - expect(preferences.terminalFontSize).toBe(12); - expect(preferences.codeFontSize).toBe(14); - expect(resolveAppearancePreferences({ terminalFontSize: null }).terminalFontSize).toBe(null); - }); - - it("derives terminal and code sizes from the base size when not overridden", () => { - const appearance = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 15 })); - expect(appearance.terminalFontSize).toBe(10); - expect(appearance.codeFontSize).toBe(11); - expect(appearance.isTerminalFontSizeCustom).toBe(false); - expect(appearance.isCodeFontSizeCustom).toBe(false); - - const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(14); - expect(scaled.codeFontSize).toBe(17); - }); - - it("applies explicit overrides over derived values", () => { - const appearance = resolveAppearance( - resolveAppearancePreferences({ baseFontSize: 22, terminalFontSize: 8, codeFontSize: 9 }), - ); - expect(appearance.terminalFontSize).toBe(8); - expect(appearance.codeFontSize).toBe(9); - expect(appearance.isTerminalFontSizeCustom).toBe(true); - expect(appearance.isCodeFontSizeCustom).toBe(true); - }); - - it("clamps base and code font sizes", () => { - expect(normalizeBaseFontSize(4)).toBe(11); - expect(normalizeBaseFontSize(30)).toBe(22); - expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); - expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); - }); - - it("steps terminal font size within bounds", () => { - expect(stepTerminalFontSize(6, -1)).toBe(6); - }); - - it("scales markdown typography from the base size", () => { - expect(resolveMarkdownFontSizes(15)).toMatchObject({ - m: 15, - h1: 20, - bodyLineHeight: 22, - codeBlockFontSize: 12, - codeBlockLineHeight: 18, - }); - }); - - it("scales code surface geometry from the code font size", () => { - expect(resolveMobileCodeSurface(11)).toMatchObject({ - fontSize: 11, - rowHeight: 20, - }); - }); - - it("keeps explicit code word break enabled", () => { - expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); - }); - - it("preserves the no-wrap default unless wrapping is explicitly enabled", () => { - expect(resolveAppearancePreferences(undefined).codeWordBreak).toBe(false); - expect(resolveAppearancePreferences({}).codeWordBreak).toBe(false); - expect(resolveAppearancePreferences({ codeWordBreak: null }).codeWordBreak).toBe(false); - expect(resolveAppearancePreferences({ codeWordBreak: false }).codeWordBreak).toBe(false); - }); - - it("returns the authored text scale at the 16pt default", () => { - expect(DEFAULT_BASE_FONT_SIZE).toBe(16); - - const variables = resolveTextScaleVariables(DEFAULT_BASE_FONT_SIZE); - expect(variables["--text-base"]).toBe(16); - expect(variables["--text-base--line-height"]).toBe(23); - expect(variables["--text-sm"]).toBe(14); - expect(variables["--text-sm--line-height"]).toBe(19); - expect(variables["--text-lg"]).toBe(18); - expect(variables["--text-3xl"]).toBe(30); - }); - - it("scales every text variable proportionally with the base size", () => { - const smallerVariables = resolveTextScaleVariables(15); - expect(smallerVariables["--text-base"]).toBe(15); - expect(smallerVariables["--text-sm"]).toBe(13); - - const variables = resolveTextScaleVariables(20); - expect(variables["--text-base"]).toBe(20); - expect(variables["--text-base--line-height"]).toBe(29); - expect(variables["--text-sm"]).toBe(18); - expect(variables["--text-xs"]).toBe(16); - expect(variables["--text-lg"]).toBe(23); - - const smaller = resolveTextScaleVariables(11); - expect(smaller["--text-base"]).toBe(11); - expect(smaller["--text-3xs"]).toBeGreaterThanOrEqual(8); - expect(smaller["--text-3xs--line-height"]).toBeGreaterThanOrEqual(10); - }); - - it("derives native markdown typography from the base size", () => { - expect(resolveNativeMarkdownTypography(22)).toEqual({ - fontSize: 22, - lineHeight: 32, - headingFontSizes: [29, 26, 23, 21, 21, 21], - }); - }); }); diff --git a/apps/mobile/src/lib/atomic-file.test.ts b/apps/mobile/src/lib/atomic-file.test.ts index deb7c217b2f4..fb551619c4ca 100644 --- a/apps/mobile/src/lib/atomic-file.test.ts +++ b/apps/mobile/src/lib/atomic-file.test.ts @@ -99,13 +99,4 @@ describe("writeFileAtomically", () => { const file = makeFile("message.json"); await expect(writeFileAtomically(file, "{}")).rejects.toThrow("disk full"); }); - - it("does not accumulate a temp file per retry", async () => { - failMode.current = "write"; - const file = makeFile("message.json"); - for (let attempt = 0; attempt < 3; attempt += 1) { - await expect(writeFileAtomically(file, '{"ok":true}')).rejects.toThrow(); - } - expect(tempFileUris()).toEqual([]); - }); }); diff --git a/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts b/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts deleted file mode 100644 index 9f63567cfcd2..000000000000 --- a/apps/mobile/src/lib/attachmentDocumentPresentation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { attachmentDocumentPresentation } from "./attachmentDocumentPresentation"; - -it.each([ - { - kind: "markdown", - hasTable: false, - hasEnvironment: false, - rendered: true, - renderedMode: null, - activeMode: "source", - }, - { - kind: "markdown", - hasTable: false, - hasEnvironment: true, - rendered: true, - renderedMode: "markdown", - activeMode: "markdown", - }, - { - kind: "markdown", - hasTable: false, - hasEnvironment: true, - rendered: false, - renderedMode: "markdown", - activeMode: "source", - }, - { - kind: "text", - hasTable: false, - hasEnvironment: true, - rendered: true, - renderedMode: null, - activeMode: "source", - }, - { - kind: "text", - hasTable: true, - hasEnvironment: false, - rendered: true, - renderedMode: "table", - activeMode: "table", - }, - { - kind: "text", - hasTable: true, - hasEnvironment: true, - rendered: false, - renderedMode: "table", - activeMode: "source", - }, - { - kind: "html", - hasTable: false, - hasEnvironment: false, - rendered: true, - renderedMode: "html", - activeMode: "html", - }, - { - kind: "html", - hasTable: false, - hasEnvironment: true, - rendered: false, - renderedMode: "html", - activeMode: "source", - }, -] as const)("matches the available preview for %j", ({ renderedMode, activeMode, ...input }) => { - expect(attachmentDocumentPresentation(input)).toEqual({ renderedMode, activeMode }); -}); diff --git a/apps/mobile/src/lib/attachmentDownload.test.ts b/apps/mobile/src/lib/attachmentDownload.test.ts index 1832794872d4..07a0a80a0c6b 100644 --- a/apps/mobile/src/lib/attachmentDownload.test.ts +++ b/apps/mobile/src/lib/attachmentDownload.test.ts @@ -72,7 +72,6 @@ vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); import { - openAttachmentInViewer, downloadAndShareAttachment, downloadAttachmentForPreview, shareLocalAttachment, @@ -81,7 +80,6 @@ import { isForegroundHandoffActive } from "./foreground-handoff"; const NOW = 1_787_990_400_000; const DAY_MS = 24 * 60 * 60_000; -const CACHE = "file:///cache/t3-attachment-downloads"; const input = { url: "https://chosen-environment.example/api/assets/signed-token/report.pdf", attachment: { name: "report.pdf", mimeType: "application/pdf" }, @@ -135,28 +133,11 @@ describe("downloadAndShareAttachment", () => { expect(mocks.deleted).not.toHaveBeenCalled(); }); - it("shares videos even when the server serves their bytes inline", async () => { - await downloadAndShareAttachment({ - url: "https://relay-environment.example/api/assets/signed-video/clip.mp4", - attachment: { name: "clip.mp4", mimeType: 'video/mp4; codecs="avc1"' }, - signal: new AbortController().signal, - }); - - expect(mocks.share).toHaveBeenCalledWith(expect.stringMatching(/\/clip\.mp4$/), { - mimeType: "video/mp4", - dialogTitle: "clip.mp4", - }); - }); - it.each([ ["../../résumé.pdf", "résumé.pdf"], ["C:\\folder\\clip.mp4", "clip.mp4"], - ["a?query#part%2F.txt", "a?query#part%2F.txt"], - ["Report #5 - 100%.pdf", "Report #5 - 100%.pdf"], ["..", "attachment"], - [" ", "attachment"], ["\ud800file\u0000.txt", "_file_.txt"], - [".env", ".env"], ])("uses a safe basename for %j", async (name, expected) => { await downloadAndShareAttachment({ ...input, @@ -167,18 +148,6 @@ describe("downloadAndShareAttachment", () => { expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(expected); }); - it("preserves ordinary long filenames that fit within the filesystem limit", async () => { - const name = - "Project quarterly report with detailed implementation and delivery notes for August 2026.pdf"; - await downloadAndShareAttachment({ - ...input, - attachment: { ...input.attachment, name }, - signal: new AbortController().signal, - }); - const file = mocks.download.mock.calls[0]![1] as { uri: string }; - expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(name); - }); - it("bounds the UTF-8 filename length while preserving its extension", async () => { await downloadAndShareAttachment({ ...input, @@ -218,23 +187,6 @@ describe("downloadAndShareAttachment", () => { expect(mocks.share).not.toHaveBeenCalled(); }); - it("does not open a late download after cancellation", async () => { - const started = Promise.withResolvers<{ uri: string }>(); - const download = Promise.withResolvers<{ uri: string }>(); - mocks.download.mockImplementation((_url: string, file: { uri: string }) => { - started.resolve(file); - return download.promise; - }); - const controller = new AbortController(); - const task = downloadAndShareAttachment({ ...input, signal: controller.signal }); - const file = await started.promise; - controller.abort(); - download.resolve(file); - await task; - expect(mocks.share).not.toHaveBeenCalled(); - expect(mocks.deleted).toHaveBeenCalledTimes(1); - }); - it("retains an export when its row unmounts during the native handoff", async () => { const opened = Promise.withResolvers(); const share = Promise.withResolvers(); @@ -260,18 +212,6 @@ describe("downloadAndShareAttachment", () => { expect(mocks.deleted).toHaveBeenCalledTimes(1); }); - it("removes expired exports while leaving recent and unrelated cache entries alone", async () => { - const old = `${CACHE}/${NOW - DAY_MS - 1}-00000000-0000-4000-8000-000000000010`; - const recent = `${CACHE}/${NOW - DAY_MS + 1}-00000000-0000-4000-8000-000000000011`; - const unrelated = `${CACHE}/unrelated`; - mocks.directories.add(old).add(recent).add(unrelated); - - await downloadAndShareAttachment({ ...input, signal: new AbortController().signal }); - expect(mocks.deleted.mock.calls).toEqual([[old]]); - expect(mocks.directories.has(recent)).toBe(true); - expect(mocks.directories.has(unrelated)).toBe(true); - }); - it("does not prune an active export even if it passes the cache expiry", async () => { const opened = Promise.withResolvers(); const share = Promise.withResolvers(); @@ -404,29 +344,3 @@ describe("attachment preview files", () => { expect(mocks.deleted).toHaveBeenCalledTimes(1); }); }); - -describe("document viewer handoff", () => { - it("opens a cache copy with its MIME type and retains it for the viewer", async () => { - await openAttachmentInViewer({ - uri: "file:///documents/report.pdf", - attachment: input.attachment, - signal: new AbortController().signal, - }); - expect(mocks.open).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], "application/pdf"); - expect(mocks.share).not.toHaveBeenCalled(); - expect(mocks.deleted).not.toHaveBeenCalled(); - }); - it("cleans up when no viewer handles the document", async () => { - mocks.open.mockRejectedValue(new Error("No viewer")); - await expect( - openAttachmentInViewer({ - uri: input.url, - attachment: input.attachment, - signal: new AbortController().signal, - }), - ).rejects.toThrow("No viewer"); - expect(mocks.download).toHaveBeenCalledTimes(1); - expect(mocks.deleted).toHaveBeenCalledTimes(1); - expect(isForegroundHandoffActive()).toBe(false); - }); -}); diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index f2572161bb2c..70d933805d9c 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -139,10 +139,6 @@ const documentPickedImage = { } as const satisfies DraftComposerAttachment; describe("validateDraftFileAttachments", () => { - it("allows legacy image-only sends without server config", () => { - expect(validateDraftFileAttachments({ attachments: [image], serverConfig: null })).toBeNull(); - }); - it("blocks files while config is unknown or file uploads are unsupported", () => { expect(validateDraftFileAttachments({ attachments: [file], serverConfig: null })).toBe( "Server attachment support is still loading.", @@ -294,24 +290,6 @@ describe("prepareTurnAttachments", () => { expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); }); - it("keeps existing image attachments on the legacy wire path", async () => { - const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] }); - - expect(prepared.status).toBe("ready"); - if (prepared.status !== "ready") return; - expect(prepared.attachments).toEqual([ - { - type: "image", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 3, - dataUrl: "data:image/png;base64,YWJj", - }, - ]); - expect(prepared.pendingAttachmentIds).toEqual([]); - expect(mocks.upload).not.toHaveBeenCalled(); - }); - it("inlines a file-backed image lazily when the server lacks image uploads", async () => { const prepared = await prepareTurnAttachments({ environmentId, @@ -333,6 +311,7 @@ describe("prepareTurnAttachments", () => { expect(mocks.upload).not.toHaveBeenCalled(); }); + // regression: #9713 read file-backed image drafts before enabling them it("reads restored images from the current iOS document container for legacy sends", async () => { const fileName = "33333333-3333-4333-8333-333333333333-photo.png"; mocks.documentUri = @@ -348,6 +327,7 @@ describe("prepareTurnAttachments", () => { ); }); + // regression: #9713 read file-backed image drafts before enabling them it("rejects a legacy image without inline bytes or a file", async () => { const { fileUri: _, ...missingImage } = fileBackedImage; await expect( @@ -379,6 +359,7 @@ describe("prepareTurnAttachments", () => { }, ); + // regression: #9713 read file-backed image drafts before enabling them it("uploads a file-backed image from its owned copy without staging base64", async () => { const prepared = await prepareTurnAttachments({ environmentId, @@ -542,30 +523,6 @@ describe("prepareTurnAttachments", () => { ]); }); - it("keeps the uploaded id when a document-picked picture uploads as an image", () => { - expect( - withUploadedMobileAttachmentReferences({ - environmentId, - attachments: [documentPickedImage], - uploadedAttachments: [ - { - type: "image", - id: "pending-promoted-png", - name: documentPickedImage.name, - mimeType: "image/png", - sizeBytes: documentPickedImage.sizeBytes, - }, - ], - }), - ).toEqual([ - { - ...documentPickedImage, - uploadedAttachmentId: "pending-promoted-png", - uploadEnvironmentId: environmentId, - }, - ]); - }); - it("reuses a pending file upload from a previous outbox attempt", async () => { const previouslyUploaded = { ...file, @@ -775,38 +732,6 @@ describe("prepareTurnAttachments", () => { expect(result.status).toBe("abandoned"); expect(removeCallsFor(MINTED_ID)).toBe(1); }); - - it("deletes the minted uploads when persisting the references throws", async () => { - await expect( - prepareTurnAttachments({ - environmentId, - attachments: [file], - persistUploadedReferences: async () => { - throw new Error("draft write failed"); - }, - }), - ).rejects.toThrow("draft write failed"); - - expect(removeCallsFor(MINTED_ID)).toBe(1); - }); - - it("skips persisting when every reference is already stored", async () => { - const previouslyUploaded = { - ...file, - uploadedAttachmentId: "pending-existing-pdf", - uploadEnvironmentId: environmentId, - }; - const persist = vi.fn(async () => "persisted" as const); - - const prepared = await prepareTurnAttachments({ - environmentId, - attachments: [previouslyUploaded], - persistUploadedReferences: persist, - }); - - expect(prepared.status).toBe("ready"); - expect(persist).not.toHaveBeenCalled(); - }); }); describe("releasePendingAttachmentUploads", () => { @@ -814,17 +739,6 @@ describe("releasePendingAttachmentUploads", () => { mocks.runAtomCommand.mockReset(); }); - it("retries a failed delete once before reporting it", async () => { - mocks.runAtomCommand - .mockResolvedValueOnce({ _tag: "Failure", error: new Error("offline") }) - .mockResolvedValue({ _tag: "Success", value: undefined }); - - await expect( - releasePendingAttachmentUploads(environmentId, ["pending-a"]), - ).resolves.toBeUndefined(); - expect(mocks.runAtomCommand).toHaveBeenCalledTimes(2); - }); - it("throws when a delete keeps failing so the caller sees the leak", async () => { mocks.runAtomCommand.mockResolvedValue({ _tag: "Failure", error: new Error("offline") }); diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts index 1b8b9cc6d64c..9e15ee361bcd 100644 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -1,84 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -describe("composerChipSizeSuffix", () => { - it("labels attachment records with a human size, matching web's chip", async () => { - const { composerChipSizeSuffix } = await import("./composerChipPresentation"); - expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 1024 })).toBe("1 KB"); - expect(composerChipSizeSuffix({ kind: "file", sizeBytes: 3_700_000 })).toBe("3.5 MB"); - expect(composerChipSizeSuffix({ kind: "image", sizeBytes: 2048 })).toBe("2 KB"); - }); - - it("adds nothing for records that carry no bytes", async () => { - const { composerChipSizeSuffix } = await import("./composerChipPresentation"); - expect(composerChipSizeSuffix({ kind: "terminal" })).toBe(""); - expect(composerChipSizeSuffix({ kind: "file" })).toBe(""); - expect(composerChipSizeSuffix(undefined)).toBe(""); - }); -}); - -describe("contextChipPresentation image detection", () => { - it("treats a picture attached through the file picker as an image", async () => { - const { contextChipPresentation } = await import("./composerChipPresentation"); - expect( - contextChipPresentation("file", { kind: "file", name: "IMG_4997.PNG", mimeType: "" }), - ).toEqual({ accent: "#d55665", symbol: "photo" }); - expect( - contextChipPresentation("file", { - kind: "file", - name: "shot", - mimeType: "image/jpeg", - }), - ).toEqual({ accent: "#d55665", symbol: "photo" }); - }); - - it("leaves genuine documents and videos alone", async () => { - const { contextChipPresentation } = await import("./composerChipPresentation"); - expect( - contextChipPresentation("file", { kind: "file", name: "notes.txt", mimeType: "text/plain" }), - ).toEqual({ accent: "#0090cd", symbol: "doc" }); - expect( - contextChipPresentation("file", { kind: "file", name: "clip.mp4", mimeType: "video/mp4" }), - ).toEqual({ accent: "#d06217", symbol: "play.rectangle" }); - }); -}); - -describe("pull request chip status", () => { - const chip = async (state: string, isDraft = false) => { - const { contextChipPresentation } = await import("./composerChipPresentation"); - return contextChipPresentation("review-comment", { - kind: "review-comment", - sectionId: "pull-request:10978", - pullRequest: { state, isDraft }, - }); - }; - - it("colours a pull request by its state, the way web and the forge do", async () => { - expect((await chip("open")).accent).toBe("#009f6e"); - expect((await chip("open", true)).accent).toBe("#7f8793"); - expect((await chip("merged")).accent).toBe("#8a70dd"); - expect((await chip("closed")).accent).toBe("#d55665"); - }); - - it("keeps one glyph across every state, so only colour carries the status", async () => { - const symbols = await Promise.all( - [chip("open"), chip("open", true), chip("merged"), chip("closed")].map( - async (pending) => (await pending).symbol, - ), - ); - expect(new Set(symbols)).toEqual(new Set(["git-pull-request"])); - }); - - it("falls back to the generic pull request chip when the state is unknown", async () => { - const { contextChipPresentation } = await import("./composerChipPresentation"); - expect( - contextChipPresentation("review-comment", { - kind: "review-comment", - sectionId: "pull-request:1", - }).accent, - ).toBe("#7079e4"); - }); -}); - describe("GitHub issue chip status", () => { it("colours an issue green while open and purple once closed, keeping one glyph", async () => { const { contextChipPresentation } = await import("./composerChipPresentation"); @@ -89,6 +10,30 @@ describe("GitHub issue chip status", () => { }); expect(open.accent).toBe("#009f6e"); expect(closed.accent).toBe("#8a70dd"); - expect(closed.symbol).toBe(open.symbol); + expect(closed.icon).toBe(open.icon); + }); +}); + +describe("fork context chip assets", () => { + it.each([ + ["linear-issue", "ticket"], + ["slack-thread", "hash"], + ["discord-thread", "messages-square"], + ["repository", "folder-git-2"], + ["checkout", "laptop"], + ["unread-link", "link"], + ])("keeps %s distinguishable with a shared native icon", async (kind, icon) => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + expect(contextChipPresentation(kind).icon).toBe(icon); + }); + + it.each([ + ["release", "tag"], + ["tag", "tag"], + ["branch", "git-branch"], + ["commit", "git-commit-horizontal"], + ])("renders a GitHub %s with its own glyph", async (refType, icon) => { + const { contextChipPresentation } = await import("./composerChipPresentation"); + expect(contextChipPresentation("github-ref", { refType }).icon).toBe(icon); }); }); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index a42a2f6b8223..965bd613bb12 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -1,31 +1,56 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; + +import type { CONTEXT_CHIP_ICON_SOURCES } from "./contextChipIcons.generated"; +import { resolveMarkdownFileIcon, type MarkdownFileIcon } from "./markdownLinks"; + +export type ContextChipIcon = keyof typeof CONTEXT_CHIP_ICON_SOURCES; + +interface ContextChipPresentation { + readonly accent?: string; + readonly icon: ContextChipIcon | null; +} + /** * These are the sRGB form of the same OKLCH set web uses, so a chip looks the same on every - * surface. See `composerInlineChip.ts`. + * surface. See `ContextChip.tsx`. */ const CONTEXT_CHIP_PRESENTATIONS = { - image: { accent: "#d55665", symbol: "photo" }, - video: { accent: "#d06217", symbol: "play.rectangle" }, - file: { accent: "#0090cd", symbol: "doc" }, - mention: { accent: "#0096af", symbol: "doc" }, - terminal: { accent: "#009f6e", symbol: "terminal" }, - element: { accent: "#b87501", symbol: "cursorarrow.click" }, - "preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" }, - "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, - "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, - skill: { accent: "#b261be", symbol: "cube" }, - "linear-issue": { accent: "#617de6", symbol: "ticket" }, - "github-issue": { accent: "#009f6e", symbol: "smallcircle.filled.circle" }, - "github-ref": { accent: "#7f8793", symbol: "tag" }, - "slack-thread": { accent: "#c6589c", symbol: "number" }, - "discord-thread": { accent: "#5865F2", symbol: "bubble.left.and.bubble.right" }, - repository: { accent: "#009c9c", symbol: "folder" }, - checkout: { accent: "#0096af", symbol: "laptopcomputer" }, - "unread-link": { accent: "#d55665", symbol: "link" }, - model: { accent: "#d06217", symbol: "cpu" }, -} as const; + image: { accent: "#d55665", icon: "image" }, + video: { accent: "#d06217", icon: "film" }, + file: { accent: "#0090cd", icon: null }, + mention: { accent: "#0096af", icon: null }, + terminal: { accent: "#009f6e", icon: "terminal" }, + element: { accent: "#b87501", icon: "mouse-pointer-click" }, + "preview-annotation": { + accent: "#b87501", + icon: "mouse-pointer-click", + }, + "review-comment": { accent: "#8a70dd", icon: "message-circle" }, + "pull-request": { + accent: "#7079e4", + icon: "git-pull-request-arrow", + }, + skill: { accent: "#b261be", icon: "skill" }, + "linear-issue": { accent: "#617de6", icon: "ticket" }, + "github-issue": { accent: "#009f6e", icon: "circle-dot" }, + "github-ref": { accent: "#7f8793", icon: "tag" }, + "slack-thread": { accent: "#c6589c", icon: "hash" }, + "discord-thread": { accent: "#5865F2", icon: "messages-square" }, + repository: { accent: "#009c9c", icon: "folder-git-2" }, + checkout: { accent: "#0096af", icon: "laptop" }, + "unread-link": { accent: "#d55665", icon: "link" }, + model: { accent: "#d06217", icon: "cpu" }, + thread: { + accent: "#009c96", + icon: "messages-square", + }, +} as const satisfies Record; + +export const UNAVAILABLE_CONTEXT_CHIP_PRESENTATION: ContextChipPresentation = { + icon: "circle-dashed", +}; export function composerChipSizeSuffix(record?: { readonly kind?: string; @@ -36,27 +61,24 @@ export function composerChipSizeSuffix(record?: { } const PULL_REQUEST_CHIP_PRESENTATIONS = { - open: { accent: "#009f6e", symbol: "git-pull-request" }, - draft: { accent: "#7f8793", symbol: "git-pull-request" }, - merged: { accent: "#8a70dd", symbol: "git-pull-request" }, - closed: { accent: "#d55665", symbol: "git-pull-request" }, -} as const; + open: { accent: "#009f6e", icon: "git-pull-request-arrow" }, + draft: { accent: "#7f8793", icon: "git-pull-request-draft" }, + merged: { accent: "#8a70dd", icon: "git-merge" }, + closed: { accent: "#d55665", icon: "git-pull-request-closed" }, +} as const satisfies Record; -/** - * GitHub's glyph per ref kind, with a release drawn as the tag it publishes. These are SF Symbol - * names; Android draws the same names from lucide geometry in `T3ContextChip.kt`. - */ -const GITHUB_REF_CHIP_SYMBOLS = { +/** A release uses the tag it publishes; both native surfaces share the same glyph assets. */ +const GITHUB_REF_CHIP_ICONS = { release: "tag", tag: "tag", - branch: "arrow.triangle.branch", - commit: "circle.and.line.horizontal", + branch: "git-branch", + commit: "git-commit-horizontal", } as const; /** A GitHub issue chip is green while open and purple once closed, the way GitHub colours it. */ const CLOSED_GITHUB_ISSUE_CHIP_PRESENTATION = { accent: "#8a70dd", - symbol: "smallcircle.filled.circle", + icon: "circle-dot", } as const; export function contextChipPresentation( @@ -73,7 +95,7 @@ export function contextChipPresentation( readonly isDraft?: boolean; }; }, -) { +): ContextChipPresentation { const presentationKind = kind === "file" && videoMimeType({ @@ -100,11 +122,11 @@ export function contextChipPresentation( if ( presentationKind === "github-ref" && record?.refType !== undefined && - Object.hasOwn(GITHUB_REF_CHIP_SYMBOLS, record.refType) + Object.hasOwn(GITHUB_REF_CHIP_ICONS, record.refType) ) { return { ...CONTEXT_CHIP_PRESENTATIONS["github-ref"], - symbol: GITHUB_REF_CHIP_SYMBOLS[record.refType as keyof typeof GITHUB_REF_CHIP_SYMBOLS], + icon: GITHUB_REF_CHIP_ICONS[record.refType as keyof typeof GITHUB_REF_CHIP_ICONS], }; } if (presentationKind === "github-issue" && record?.state === "closed") { @@ -112,5 +134,24 @@ export function contextChipPresentation( } return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind) ? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS] - : CONTEXT_CHIP_PRESENTATIONS.file; + : UNAVAILABLE_CONTEXT_CHIP_PRESENTATION; +} + +export type ContextChipIconRef = + | { readonly kind: "chip"; readonly name: ContextChipIcon } + | { readonly kind: "file"; readonly name: MarkdownFileIcon }; + +export function contextChipIcon( + presentation: Pick, + fileName: string, +): ContextChipIconRef { + return presentation.icon + ? { kind: "chip", name: presentation.icon } + : { kind: "file", name: resolveMarkdownFileIcon(fileName) }; +} + +export function contextRecordFileName(record?: object): string { + if (record && "path" in record && typeof record.path === "string") return record.path; + if (record && "name" in record && typeof record.name === "string") return record.name; + return ""; } diff --git a/apps/mobile/src/lib/composerContextClipboard.test.ts b/apps/mobile/src/lib/composerContextClipboard.test.ts index d37e0a9ae6fd..dffc8b00a5d7 100644 --- a/apps/mobile/src/lib/composerContextClipboard.test.ts +++ b/apps/mobile/src/lib/composerContextClipboard.test.ts @@ -244,6 +244,7 @@ describe("mobile context clipboard imports", () => { expect(mocks.download).not.toHaveBeenCalled(); }); + // regression: #18322 don't send or paste attached threads a machine can't read it("reports foreign threads and failed files together while keeping the rest of a paste", async () => { mocks.download.mockRejectedValueOnce(new Error("source disconnected")); const result = await importComposerContextClipboard( @@ -295,11 +296,4 @@ describe("mobile context clipboard imports", () => { collectComposerContextReferences(result!.text).map((reference) => reference.contextId), ).toEqual(result!.context.records.map((record) => record.contextId)); }); - - it("refuses an overflowing context paste before copying files", async () => { - await expect( - importComposerContextClipboard(clipboard, 0, new AbortController().signal, 200), - ).rejects.toThrow("Remove some context"); - expect(mocks.download).not.toHaveBeenCalled(); - }); }); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 1d31f1c8022a..14d1d630439b 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -180,20 +180,6 @@ describe("composer file attachments", () => { }, ); - it.each([ - { size: { width: 4032, height: 3024 }, resizes: [{ width: 2000, height: 1500 }] }, - { size: { width: 3024, height: 4032 }, resizes: [{ width: 1500, height: 2000 }] }, - { size: { width: 2000, height: 1500 }, resizes: [] }, - ])("bounds a $size.width x $size.height photo to a 2000 px longest edge", async (input) => { - native.size = input.size; - mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [photo] }); - - await pickComposerImages({ existingCount: 0 }); - - expect(native.resizes).toEqual(input.resizes); - expect(mocks.release).toHaveBeenCalledTimes(input.resizes.length + 1); - }); - it.each([ { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, @@ -222,22 +208,6 @@ describe("composer file attachments", () => { ]); }); - it("renders a supported original that exceeds the image limit instead of rejecting it", async () => { - mocks.pickMedia.mockResolvedValue({ - canceled: false, - assets: [{ ...photo, fileName: "photo.jpg", mimeType: "image/jpeg" }], - }); - mocks.size.mockReturnValue(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); - - const result = await pickComposerImages({ existingCount: 0 }); - - expect(mocks.manipulate).toHaveBeenCalledWith(photo.uri); - expect(result.error).toBeNull(); - expect(result.images).toEqual([ - expect.objectContaining({ name: "photo.jpg", mimeType: "image/jpeg", sizeBytes: 4 }), - ]); - }); - it("measures the picker file instead of trusting the reported size", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, @@ -252,6 +222,7 @@ describe("composer file attachments", () => { expect(result.images).toEqual([expect.objectContaining({ mimeType: "image/jpeg" })]); }); + // regression: #11440 render photo library picks to a bounded JPEG off the JS thread it("renders a supported original whose size cannot be measured", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, @@ -295,6 +266,7 @@ describe("composer file attachments", () => { expect(result.error).toBe("Failed to read 'missing.gif'."); }); + // regression: #11440 render photo library picks to a bounded JPEG off the JS thread it("reports a photo the native renderer cannot decode", async () => { mocks.manipulate.mockImplementation(() => ({ resize: () => { @@ -371,21 +343,6 @@ describe("composer file attachments", () => { expect(mocks.delete).not.toHaveBeenCalled(); }); - it("keeps image-only destinations on the image picker path", async () => { - mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image] }); - - const result = await pickComposerImages({ existingCount: 0 }); - - expect(mocks.pickMedia).toHaveBeenCalledWith( - expect.objectContaining({ mediaTypes: ["images"] }), - ); - expect(result.images).toEqual([ - expect.objectContaining({ type: "image", name: "photo.png" }), - ]); - expect(result.error).toBeNull(); - expect(mocks.copy).not.toHaveBeenCalled(); - }); - it("does not persist videos when the destination lacks file support", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] }); @@ -416,13 +373,6 @@ describe("composer file attachments", () => { }); it.each([ - { - reason: "picker size exceeds the server limit", - reported: 2 * 1024 * 1024, - stored: 42, - limit: 1024 * 1024, - error: "'clip.mov' exceeds the 1 MB attachment limit.", - }, { reason: "actual size exceeds the server limit", reported: 42, @@ -608,18 +558,6 @@ describe("composer file attachments", () => { expect(mocks.open).not.toHaveBeenCalled(); }); - it("reports picker failures and releases the foreground handoff", async () => { - mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable.")); - - await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ - files: [], - error: "The document provider is unavailable.", - }); - - expect(isForegroundHandoffActive()).toBe(false); - expect(mocks.copy).not.toHaveBeenCalled(); - }); - it("does not open the picker when the draft has no remaining attachment slots", async () => { await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({ files: [], @@ -630,25 +568,6 @@ describe("composer file attachments", () => { expect(isForegroundHandoffActive()).toBe(false); }); - it("falls back to a usable name when the picker reports a blank one", async () => { - mocks.pickFile.mockResolvedValue({ - canceled: false, - assets: [ - { - uri: "file:///downloads/unnamed", - name: " ", - mimeType: "application/pdf", - size: 42, - }, - ], - }); - - const result = await pickComposerFiles({ existingCount: 0 }); - expect(result.error).toBeNull(); - expect(result.files).toHaveLength(1); - expect(result.files[0]?.name).toBe("file"); - }); - it("rejects files that exceed the environment's advertised upload limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, @@ -669,27 +588,6 @@ describe("composer file attachments", () => { expect(mocks.copy).not.toHaveBeenCalled(); }); - it("never accepts files above the 50 MB contract limit", async () => { - mocks.pickFile.mockResolvedValue({ - canceled: false, - assets: [ - { - uri: "file:///downloads/archive.zip", - name: "archive.zip", - mimeType: "application/zip", - size: 51 * 1024 * 1024, - }, - ], - }); - - await expect( - pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }), - ).resolves.toEqual({ - files: [], - error: "'archive.zip' exceeds the 50 MB attachment limit.", - }); - }); - it("rejects a file that grew after the picker reported its size", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, @@ -755,26 +653,6 @@ describe("composer file attachments", () => { ); }); - it("reports an empty file without calling it oversized", async () => { - mocks.size.mockReturnValue(0); - mocks.pickFile.mockResolvedValue({ - canceled: false, - assets: [ - { - uri: "file:///downloads/empty.txt", - name: "empty.txt", - mimeType: "text/plain", - size: 0, - }, - ], - }); - - await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ - files: [], - error: "'empty.txt' is empty or could not be read.", - }); - }); - it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => { const reader = { readBytes: vi diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 412d662ef952..9804a6d0e585 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -103,12 +103,7 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { - convertPastedImagesToAttachments, - createPastedTextComposerAttachment, - isOwnedPastedImageUri, - pasteComposerClipboard, -} from "./composerImages"; +import { convertPastedImagesToAttachments, pasteComposerClipboard } from "./composerImages"; describe("composer clipboard paste", () => { beforeEach(() => { @@ -137,23 +132,6 @@ describe("composer clipboard paste", () => { ).toEqual({ images: [], text: null, error: expect.stringContaining("up to") }); expect(clipboard.getStringAsync).not.toHaveBeenCalled(); }); - - it("returns plain text without image chips", async () => { - expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ - images: [], - text: "clipboard text", - error: null, - }); - }); - - it("reports an empty text clipboard", async () => { - clipboard.getStringAsync.mockResolvedValue(""); - expect(await pasteComposerClipboard({ existingCount: 0 })).toEqual({ - images: [], - text: null, - error: "Clipboard is empty.", - }); - }); }); describe("oversized pasted images", () => { @@ -205,16 +183,6 @@ describe("oversized pasted images", () => { }), ]); }); - - it("keeps a 2000px clipboard image byte for byte without rendering it", async () => { - const pasted = pngDataUrl(2000, 1200); - clipboard.getImageAsync.mockResolvedValue({ data: pasted }); - - const result = await pasteComposerClipboard({ existingCount: 0 }); - - expect(manipulator.manipulate).not.toHaveBeenCalled(); - expect(result.images).toEqual([expect.objectContaining({ dataUrl: pasted })]); - }); }); describe("native pasted image cleanup", () => { @@ -222,16 +190,6 @@ describe("native pasted image cleanup", () => { files.clear(); }); - it("recognizes only files created in the native composer paste directory", () => { - expect( - isOwnedPastedImageUri( - "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png", - ), - ).toBe(true); - expect(isOwnedPastedImageUri("file:///private/var/mobile/photos/id.png")).toBe(false); - expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false); - }); - it("converts owned files to data-backed previews and deletes the source", async () => { const uri = "file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png"; @@ -270,26 +228,6 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); - - it("persists folded text unchanged in the app-owned attachment directory", async () => { - const text = "first line\nUnicode: 🙂\n"; - const attachment = await createPastedTextComposerAttachment({ - text, - name: "pasted-text.txt", - maxBytes: 1024, - }); - - expect(attachment).toEqual({ - id: "attachment-id", - type: "file", - name: "pasted-text.txt", - mimeType: "text/plain;charset=utf-8", - sizeBytes: new TextEncoder().encode(text).byteLength, - fileUri: "file:///documents/t3-composer-attachments/attachment-id-pasted-text.txt", - source: { _tag: "pasted-text" }, - }); - expect(files.get(attachment.fileUri)?.text).toBe(text); - }); }); describe("composerStripAttachments", () => { @@ -318,17 +256,6 @@ describe("composerStripAttachments", () => { fileUri: "file:///notes.txt", }; - it("keeps media, because a thumbnail is the only way to see it", async () => { - const { composerStripAttachments } = await import("./composerImages"); - const kept = composerStripAttachments([image, video] as never); - expect(kept.map((a) => a.id)).toEqual(["img-1", "vid-1"]); - }); - - it("never shows a non-media file above the composer", async () => { - const { composerStripAttachments } = await import("./composerImages"); - expect(composerStripAttachments([doc] as never)).toEqual([]); - }); - it("keeps media beside a document rather than dropping the whole strip", async () => { const { composerStripAttachments } = await import("./composerImages"); expect(composerStripAttachments([doc, image, video] as never).map((a) => a.id)).toEqual([ @@ -336,78 +263,4 @@ describe("composerStripAttachments", () => { "vid-1", ]); }); - - it("treats a picture picked through the document picker as media", async () => { - const { composerStripAttachments } = await import("./composerImages"); - const pickedImage = { - id: "pick-1", - type: "file" as const, - name: "photo.png", - mimeType: "image/png", - sizeBytes: 30, - fileUri: "file:///photo.png", - }; - expect(composerStripAttachments([pickedImage] as never).map((a) => a.id)).toEqual(["pick-1"]); - }); -}); - -describe("composerAttachmentInlineUri", () => { - it("offers the inline bytes of a picture that owns no file", async () => { - const { composerAttachmentInlineUri } = await import("./composerImages"); - expect( - composerAttachmentInlineUri({ - id: "img-1", - type: "image", - name: "IMG_0111.jpg", - mimeType: "image/jpeg", - sizeBytes: 9_100_000, - dataUrl: "data:image/jpeg;base64,AAAA", - previewUri: "ph://asset", - } as never), - ).toBe("data:image/jpeg;base64,AAAA"); - }); - - it("falls back to the preview when a picture kept only its asset uri", async () => { - const { composerAttachmentInlineUri } = await import("./composerImages"); - expect( - composerAttachmentInlineUri({ - id: "img-2", - type: "image", - name: "IMG_0112.jpg", - mimeType: "image/jpeg", - sizeBytes: 10, - previewUri: "ph://asset", - } as never), - ).toBe("ph://asset"); - }); - - it("leaves a file-backed attachment to the retain-lease path", async () => { - const { composerAttachmentInlineUri } = await import("./composerImages"); - expect( - composerAttachmentInlineUri({ - id: "img-3", - type: "image", - name: "IMG_0113.jpg", - mimeType: "image/jpeg", - sizeBytes: 10, - fileUri: "file:///owned.jpg", - previewUri: "file:///owned.jpg", - } as never), - ).toBeUndefined(); - }); - - it("has nothing to offer for a plain file or a missing attachment", async () => { - const { composerAttachmentInlineUri } = await import("./composerImages"); - expect(composerAttachmentInlineUri(undefined)).toBeUndefined(); - expect( - composerAttachmentInlineUri({ - id: "doc-1", - type: "file", - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 4, - fileUri: "file:///notes.txt", - } as never), - ).toBeUndefined(); - }); }); diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6cc9ce8607d1..bb0a9c94ca91 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; -import { isRelayManagedConnection, toStableSavedRemoteConnection } from "./connection"; -import { authClientMetadata } from "./authClientMetadata"; +import { toStableSavedRemoteConnection } from "./connection"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); const mobileDevice = vi.hoisted(() => ({ @@ -38,56 +37,6 @@ describe("mobile remote connection records", () => { mobileDevice.modelName = "iPhone 15 Pro"; }); - it("identifies mobile token exchanges for authorized-client presentation", () => { - expect(authClientMetadata()).toEqual({ - label: "T3 Code Mobile", - deviceType: "mobile", - os: "iOS", - osMajorVersion: 18, - deviceModel: "iPhone 15 Pro", - surface: "mobile", - }); - }); - - it("includes only the Android major version and hardware model", () => { - mobilePlatform.OS = "android"; - mobileDevice.osVersion = "15.2.1"; - mobileDevice.modelName = "Pixel 9"; - - expect(authClientMetadata()).toMatchObject({ - os: "Android", - osMajorVersion: 15, - deviceModel: "Pixel 9", - }); - }); - - it("identifies native tablets separately from phones", () => { - mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; - mobileDevice.modelName = "iPad Pro 13-inch"; - - expect(authClientMetadata()).toMatchObject({ - deviceType: "tablet", - os: "iOS", - deviceModel: "iPad Pro 13-inch", - }); - }); - - it("includes the mobile app version when the client provides it", () => { - expect(authClientMetadata("1.2.3")).toMatchObject({ - surface: "mobile", - appVersion: "1.2.3", - }); - }); - - it("recognizes explicitly managed relay connections", () => { - expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); - }); - - it("keeps existing DPoP tunnel records read-only after upgrading", () => { - expect(isRelayManagedConnection({ authenticationMethod: "dpop" })).toBe(true); - expect(isRelayManagedConnection({ authenticationMethod: "bearer" })).toBe(false); - }); - it("drops short-lived managed environment credentials from stable records", () => { const connection = { environmentId: EnvironmentId.make("environment-1"), diff --git a/apps/mobile/src/lib/contextChipIconSource.ts b/apps/mobile/src/lib/contextChipIconSource.ts new file mode 100644 index 000000000000..6aa92d8e47d8 --- /dev/null +++ b/apps/mobile/src/lib/contextChipIconSource.ts @@ -0,0 +1,13 @@ +import type { ContextChipIconRef } from "./composerChipPresentation"; +import { CONTEXT_CHIP_ICON_SOURCES } from "./contextChipIcons.generated"; +import { markdownFileIconSource, markdownIconAssetUri } from "./markdownFileIcons"; + +export function contextChipIconSource(icon: ContextChipIconRef) { + return icon.kind === "chip" + ? CONTEXT_CHIP_ICON_SOURCES[icon.name] + : markdownFileIconSource(icon.name); +} + +export function contextChipIconUri(icon: ContextChipIconRef): string | null { + return markdownIconAssetUri(contextChipIconSource(icon)) ?? null; +} diff --git a/apps/mobile/src/lib/contextChipIcons.generated.ts b/apps/mobile/src/lib/contextChipIcons.generated.ts new file mode 100644 index 000000000000..c3ab87ecb8d4 --- /dev/null +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -0,0 +1,26 @@ +import type { ImageSourcePropType } from "react-native"; + +export const CONTEXT_CHIP_ICON_SOURCES = { + "circle-dashed": require("../../assets/context-chip-icons/circle-dashed.png"), + "circle-dot": require("../../assets/context-chip-icons/circle-dot.png"), + cpu: require("../../assets/context-chip-icons/cpu.png"), + film: require("../../assets/context-chip-icons/film.png"), + "folder-git-2": require("../../assets/context-chip-icons/folder-git-2.png"), + "git-branch": require("../../assets/context-chip-icons/git-branch.png"), + "git-commit-horizontal": require("../../assets/context-chip-icons/git-commit-horizontal.png"), + "git-merge": require("../../assets/context-chip-icons/git-merge.png"), + "git-pull-request-arrow": require("../../assets/context-chip-icons/git-pull-request-arrow.png"), + "git-pull-request-closed": require("../../assets/context-chip-icons/git-pull-request-closed.png"), + "git-pull-request-draft": require("../../assets/context-chip-icons/git-pull-request-draft.png"), + hash: require("../../assets/context-chip-icons/hash.png"), + image: require("../../assets/context-chip-icons/image.png"), + laptop: require("../../assets/context-chip-icons/laptop.png"), + link: require("../../assets/context-chip-icons/link.png"), + "message-circle": require("../../assets/context-chip-icons/message-circle.png"), + "messages-square": require("../../assets/context-chip-icons/messages-square.png"), + "mouse-pointer-click": require("../../assets/context-chip-icons/mouse-pointer-click.png"), + skill: require("../../assets/context-chip-icons/skill.png"), + tag: require("../../assets/context-chip-icons/tag.png"), + terminal: require("../../assets/context-chip-icons/terminal.png"), + ticket: require("../../assets/context-chip-icons/ticket.png"), +} as const satisfies Readonly>; diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts deleted file mode 100644 index 9a6b1a8910de..000000000000 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - impactAsync: vi.fn(), - selectionAsync: vi.fn(), - setStringAsync: vi.fn(), -})); - -vi.mock("expo-clipboard", () => ({ - setStringAsync: mocks.setStringAsync, -})); - -vi.mock("expo-haptics", () => ({ - ImpactFeedbackStyle: { - Light: "light", - }, - impactAsync: mocks.impactAsync, - selectionAsync: mocks.selectionAsync, -})); - -import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic"; - -describe("copyTextWithHaptic", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); - mocks.impactAsync.mockResolvedValue(undefined); - mocks.selectionAsync.mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("triggers haptic feedback without waiting for the clipboard promise", () => { - copyTextWithHaptic("trace-123"); - - expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); - expect(mocks.impactAsync).toHaveBeenCalledWith("light"); - }); - - it("preserves selection feedback for thread work rows", () => { - copyTextWithHaptic("work output", { - target: "thread-work-row", - feedback: "selection", - }); - - expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); - expect(mocks.selectionAsync).toHaveBeenCalledOnce(); - expect(mocks.impactAsync).not.toHaveBeenCalled(); - }); - - it("reports whether the clipboard write succeeded", async () => { - mocks.setStringAsync.mockResolvedValueOnce(undefined); - - await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); - }); - - it("returns false when the clipboard write fails", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); - - await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); - }); - - it("reports structured failures without including clipboard contents", async () => { - const content = "https://accounts.google.com/auth?state=private-state&code=private-code"; - const clipboardCause = new Error(`Cannot copy ${content}`); - const hapticCause = new Error(`Native failure for ${content}`); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); - mocks.impactAsync.mockRejectedValueOnce(hapticCause); - - await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" }); - - expect(consoleError).toHaveBeenCalledWith( - "Failed to copy provider-sign-in-link to the clipboard.", - expect.objectContaining({ - _tag: "CopyTextClipboardWriteError", - target: "provider-sign-in-link", - }), - ); - expect(consoleError).toHaveBeenCalledWith( - "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.", - expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }), - ); - const diagnostics = JSON.stringify(consoleError.mock.calls); - expect(diagnostics).not.toContain("private-state"); - expect(diagnostics).not.toContain("private-code"); - expect(diagnostics).not.toContain("cause"); - }); -}); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts index d4c6e5648f01..3ceb536926df 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.test.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ComposerContextId, ReviewCommentContextRecord } from "@t3tools/contracts"; import { - enrichedContextLinkPresentation, enrichedLinkVariantPattern, - enrichedSkillInstructionsPath, enrichedSkillLinkRegex, - enrichedSkillReference, ENRICHED_INLINE_FILE_LINK_REGEX, } from "./enrichedLinkPresentation"; @@ -42,37 +38,6 @@ describe("Enriched link presentation", () => { expect(regex?.exec("prefix$check next")).toBeNull(); expect(enrichedSkillLinkRegex([])).toBeNull(); }); - it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( - "recognizes and presents known skills with %s while rejecting adjacent tokens", - (sigil) => { - const skills = [{ name: "check", displayName: "Check" }]; - const regex = enrichedSkillLinkRegex(skills)!; - expect(regex.exec(`Try ${sigil}check now`)?.[0]).toBe(`${sigil}check`); - expect(enrichedSkillReference(`${sigil}check`, skills)).toBe(skills[0]); - expect(regex.test(`prefix${sigil}check`)).toBe(false); - expect(regex.test(`${sigil}check-extra`)).toBe(false); - expect(regex.test(`${sigil}unknown`)).toBe(false); - expect(regex.test(`${sigil}${sigil}check`)).toBe(false); - }, - ); - it("opens skill instructions only for filesystem paths", () => { - expect( - enrichedSkillInstructionsPath({ - name: "ketchup", - path: "/home/me/.claude/skills/ketchup/SKILL.md", - }), - ).toBe("/home/me/.claude/skills/ketchup/SKILL.md"); - expect( - enrichedSkillInstructionsPath({ name: "review", path: "C:\\skills\\review\\SKILL.md" }), - ).toBe("C:\\skills\\review\\SKILL.md"); - expect( - enrichedSkillInstructionsPath({ - name: "workspace-skill", - path: "acp://skill/workspace-skill", - }), - ).toBeUndefined(); - expect(enrichedSkillInstructionsPath({ name: "bare" })).toBeUndefined(); - }); it("matches only the complete URL, including literal regex metacharacters", () => { const url = "file:///workspace/app[1].tsx?line=3+4"; @@ -81,52 +46,4 @@ describe("Enriched link presentation", () => { expect(pattern.test(`${url}/child`)).toBe(false); expect(pattern.test("file:///workspace/app1Xtsx?line=34")).toBe(false); }); - - it("uses context identity for PR status even when labels are identical", () => { - const record: ReviewCommentContextRecord = { - version: 1, - contextId: "review_1" as ComposerContextId, - kind: "review-comment", - label: "Review", - sectionId: "pull-request:7", - sectionTitle: "Review", - filePath: "app.tsx", - startIndex: 0, - endIndex: 1, - rangeLabel: "line 1", - text: "Comment", - diff: "", - pullRequest: { - number: 7, - title: "Review", - url: "https://github.com/example/repo/pull/7", - headBranch: "feature", - baseBranch: "main", - state: "merged", - isDraft: false, - }, - }; - expect( - enrichedContextLinkPresentation("t3-context://v1/review-comment/review_1", [record]), - ).toEqual({ color: "#8a70dd", icon: "git" }); - expect( - enrichedContextLinkPresentation("t3-context://v1/review-comment/review_2", [record]), - ).toEqual({ color: "#8a70dd", icon: "markdown" }); - }); - - it("retains recognizable presentation for unavailable and future context kinds", () => { - expect(enrichedContextLinkPresentation("t3-context://v1/terminal/missing")).toEqual({ - color: "#009f6e", - icon: "bash", - }); - expect(enrichedContextLinkPresentation("t3-context://v1/skill/missing")).toEqual({ - color: "#b261be", - icon: "mcp", - }); - expect(enrichedContextLinkPresentation("t3-context://v1/future/missing")).toEqual({ - color: "#0090cd", - icon: "default", - }); - expect(enrichedContextLinkPresentation("https://example.com/terminal/missing")).toBeUndefined(); - }); }); diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 5acec18a4300..db16d0181591 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -1,39 +1,31 @@ import type { ComposerContextRecord } from "@t3tools/contracts"; -import { contextChipPresentation } from "./composerChipPresentation"; -import type { MarkdownFileIcon } from "./markdownLinks"; +import { + composerChipSizeSuffix, + contextChipIcon, + contextChipPresentation, + contextRecordFileName, + type ContextChipIconRef, + UNAVAILABLE_CONTEXT_CHIP_PRESENTATION, +} from "./composerChipPresentation"; import type { SelectableMarkdownSkill } from "../native/SelectableMarkdownText.types"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -const CONTEXT_ICONS: Readonly> = { - photo: "image", - "play.rectangle": "video", - terminal: "bash", - "cursorarrow.click": "react", - "text.bubble": "markdown", - "git-pull-request": "git", - cube: "mcp", - doc: "default", - ticket: "text", - "smallcircle.filled.circle": "git", - number: "markdown", - folder: "git", - tag: "git", - "arrow.triangle.branch": "git", - "circle.and.line.horizontal": "git", - link: "default", -}; - export function enrichedContextLinkPresentation( href: string, records?: ReadonlyArray, -) { +): { accent?: string; icon: ContextChipIconRef; label?: string } | undefined { const reference = parseComposerContextHref(href); if (!reference) return undefined; const record = records?.find((candidate) => candidate.contextId === reference.contextId); - const presentation = contextChipPresentation(reference.kind, record); + const presentation = + records && !record + ? UNAVAILABLE_CONTEXT_CHIP_PRESENTATION + : contextChipPresentation(reference.kind, record); + const size = composerChipSizeSuffix(record); return { - color: presentation.accent, - icon: CONTEXT_ICONS[presentation.symbol] ?? "default", + ...(presentation.accent ? { accent: presentation.accent } : {}), + icon: contextChipIcon(presentation, contextRecordFileName(record)), + ...(record && size ? { label: `${record.label} · ${size}` } : {}), }; } diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 50be5369c7d3..a48a4bd11462 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -4,12 +4,9 @@ import { isPdfFile } from "./filePreview"; describe("PDF preview detection", () => { it.each([ - [{ name: "download", mimeType: "application/pdf" }, true], [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], - [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], [{ name: "report.pdf", mimeType: "text/plain" }, false], - [{ name: "report.pdf.exe" }, false], [{ name: "https://example.com/page?download=report.pdf" }, false], ])("classifies %j as %s", (file, expected) => { expect(isPdfFile(file)).toBe(expected); diff --git a/apps/mobile/src/lib/filePreviewGallery.test.ts b/apps/mobile/src/lib/filePreviewGallery.test.ts index 26a649c21b37..95d52e5a7fe6 100644 --- a/apps/mobile/src/lib/filePreviewGallery.test.ts +++ b/apps/mobile/src/lib/filePreviewGallery.test.ts @@ -1,23 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../components/FilePreviewModal.types"; -import { filePreviewGallery, filePreviewKey, filePreviewName } from "./filePreviewGallery"; - -const first: FilePreviewItem = { kind: "image", uri: "https://example.com/first.png" }; -const second: FilePreviewItem = { kind: "image", uri: "https://example.com/second.png" }; +import { filePreviewGallery, filePreviewKey } from "./filePreviewGallery"; describe("image preview groups", () => { - it("preserves ordering, removes duplicates and retains the tapped transition source", () => { - const selected = { ...second, sourceIdentifier: "thumbnail-2" }; - expect(filePreviewGallery(selected, [first, second, first])).toEqual([first, selected]); - }); - - it("keeps documents out of image groups and preserves a standalone image", () => { - const document: FilePreviewItem = { kind: "pdf", uri: "file:///report.pdf" }; - expect(filePreviewGallery(first, [document])).toEqual([first]); - expect(filePreviewGallery(document, [first, second])).toEqual([document]); - }); - it("matches remote originals despite metadata and property order differences", () => { const environmentId = EnvironmentId.make("test"); const a: FilePreviewItem = { @@ -38,20 +24,4 @@ describe("image preview groups", () => { expect(filePreviewKey(a)).toBe(filePreviewKey(b)); expect(filePreviewGallery(b, [a])).toEqual([b]); }); - - it("uses actual filenames instead of captions with punctuation and paths", () => { - expect( - filePreviewName({ ...first, name: 'main: "New thread in..." shows pingdotgg/t3code' }), - ).toBe("first.png"); - expect( - filePreviewName({ - kind: "image", - uri: "https://example.com/image", - name: "test: before... / after", - }), - ).toBe("test_ before___ _ after"); - expect( - filePreviewName({ kind: "document", uri: "file:///tmp/report.docx", name: "report.docx" }), - ).toBe("report.docx"); - }); }); diff --git a/apps/mobile/src/lib/foreground-handoff.test.ts b/apps/mobile/src/lib/foreground-handoff.test.ts index 06608e692e90..eb9ca0e0cad8 100644 --- a/apps/mobile/src/lib/foreground-handoff.test.ts +++ b/apps/mobile/src/lib/foreground-handoff.test.ts @@ -3,23 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff"; describe("foreground handoff", () => { - it("is active only while a handoff is open", () => { - expect(isForegroundHandoffActive()).toBe(false); - const end = beginForegroundHandoff(); - expect(isForegroundHandoffActive()).toBe(true); - end(); - expect(isForegroundHandoffActive()).toBe(false); - }); - - it("stays active until every overlapping handoff ends", () => { - const endFirst = beginForegroundHandoff(); - const endSecond = beginForegroundHandoff(); - endFirst(); - expect(isForegroundHandoffActive()).toBe(true); - endSecond(); - expect(isForegroundHandoffActive()).toBe(false); - }); - it("tolerates an end function called twice", () => { const endFirst = beginForegroundHandoff(); const endSecond = beginForegroundHandoff(); diff --git a/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts b/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts deleted file mode 100644 index 9ad01a90b9d6..000000000000 --- a/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; - -import { hotSwappableAtomRuntime } from "./hot-swappable-atom-runtime"; - -class RuntimeValue extends Context.Service()( - "t3/mobile/test/RuntimeValue", -) {} - -function layerRuntime(value: string, events: string[]) { - return Layer.effect( - RuntimeValue, - Effect.acquireRelease( - Effect.sync(() => { - events.push(`acquire:${value}`); - return RuntimeValue.of({ value }); - }), - () => - Effect.sync(() => { - events.push(`release:${value}`); - }), - ), - ); -} - -function layerRuntimeWithRelease( - value: string, - events: string[], - release: Effect.Effect = Effect.sync(() => { - events.push(`release:${value}`); - }), -) { - return Layer.effect( - RuntimeValue, - Effect.acquireRelease( - Effect.sync(() => { - events.push(`acquire:${value}`); - return RuntimeValue.of({ value }); - }), - () => release, - ), - ); -} - -describe("hotSwappableAtomRuntime", () => { - it("rebuilds a mounted runtime in place without disturbing unrelated subscribers", () => { - vi.stubGlobal("__DEV__", true); - const registry = AtomRegistry.make(); - const accept = () => {}; - const events: string[] = []; - const id = `test-${crypto.randomUUID()}`; - const runtime = hotSwappableAtomRuntime({ - id, - hotModule: { accept }, - registry, - layer: layerRuntime("first", events), - }); - const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value))); - const values: string[] = []; - const unsubscribeRuntime = registry.subscribe( - valueAtom, - (result) => { - if (AsyncResult.isSuccess(result)) values.push(result.value); - }, - { immediate: true }, - ); - const unrelatedAtom = Atom.make(0); - const unrelatedValues: number[] = []; - const unsubscribeUnrelated = registry.subscribe(unrelatedAtom, (value) => { - unrelatedValues.push(value); - }); - registry.set(unrelatedAtom, 7); - const unwatchedDraftAtom = Atom.make("saved"); - registry.set(unwatchedDraftAtom, "edited"); - const nodesBefore = registry.getNodes().size; - - const replacement = hotSwappableAtomRuntime({ - id, - hotModule: { accept }, - registry, - layer: layerRuntime("second", events), - }); - hotSwappableAtomRuntime({ - id, - hotModule: { accept }, - registry, - layer: layerRuntime("third", events), - }); - registry.set(unrelatedAtom, 8); - - expect(replacement).toBe(runtime); - expect(events).toEqual([ - "acquire:first", - "release:first", - "acquire:second", - "release:second", - "acquire:third", - ]); - expect(values).toEqual(["first", "second", "third"]); - expect(registry.get(unwatchedDraftAtom)).toBe("edited"); - expect(unrelatedValues).toEqual([7, 8]); - expect(registry.getNodes().size).toBe(nodesBefore); - unsubscribeRuntime(); - unsubscribeUnrelated(); - registry.dispose(); - expect(events).toEqual([ - "acquire:first", - "release:first", - "acquire:second", - "release:second", - "acquire:third", - "release:third", - ]); - }); - - it("does not retain or accept a runtime outside development", () => { - vi.stubGlobal("__DEV__", false); - const registry = AtomRegistry.make(); - const accept = vi.fn(); - const layer = layerRuntime("production", []); - - const first = hotSwappableAtomRuntime({ - id: "production", - hotModule: { accept }, - registry, - layer, - }); - const second = hotSwappableAtomRuntime({ - id: "production", - hotModule: { accept }, - registry, - layer, - }); - - expect(second).not.toBe(first); - expect(accept).not.toHaveBeenCalled(); - registry.dispose(); - }); - - it("starts an asynchronous old-layer release while exposing the fresh context", async () => { - vi.stubGlobal("__DEV__", true); - const registry = AtomRegistry.make(); - const events: string[] = []; - const id = `test-${crypto.randomUUID()}`; - let finishRelease!: () => void; - const releaseGate = new Promise((resolve) => { - finishRelease = resolve; - }); - let markReleaseComplete!: () => void; - const releaseComplete = new Promise((resolve) => { - markReleaseComplete = resolve; - }); - const firstRelease = Effect.promise(async () => { - events.push("release:start:first"); - await releaseGate; - events.push("release:end:first"); - markReleaseComplete(); - }); - const runtime = hotSwappableAtomRuntime({ - id, - hotModule: { accept() {} }, - registry, - layer: layerRuntimeWithRelease("first", events, firstRelease), - }); - const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value))); - const values: string[] = []; - const unsubscribe = registry.subscribe(valueAtom, (result) => { - if (AsyncResult.isSuccess(result)) values.push(result.value); - }); - registry.get(valueAtom); - - hotSwappableAtomRuntime({ - id, - hotModule: { accept() {} }, - registry, - layer: layerRuntime("second", events), - }); - - expect(events).toEqual(["acquire:first", "release:start:first", "acquire:second"]); - expect(values).toEqual(["first", "second"]); - - finishRelease(); - await releaseComplete; - expect(events).toEqual([ - "acquire:first", - "release:start:first", - "acquire:second", - "release:end:first", - ]); - - unsubscribe(); - registry.dispose(); - }); -}); diff --git a/apps/mobile/src/lib/htmlRenderTheme.test.ts b/apps/mobile/src/lib/htmlRenderTheme.test.ts deleted file mode 100644 index bca76dbc3447..000000000000 --- a/apps/mobile/src/lib/htmlRenderTheme.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { mobileHtmlRenderTheme } from "./htmlRenderTheme"; -import { getMobileThemeVariables } from "./mobileTheme"; - -describe("mobileHtmlRenderTheme", () => { - it("paints the page with the feed's own canvas", () => { - const variables = { - ...getMobileThemeVariables("grove", "dark"), - "--color-thread-canvas": "#101010", - }; - const theme = (platform: string) => - mobileHtmlRenderTheme({ - themeId: "grove", - appearance: "dark", - variables, - systemColors: false, - platform, - }); - expect(theme("android").variables["--background"]).toBe("#101010"); - expect(theme("ios").variables["--background"]).toBe(variables["--color-screen"]); - }); - - it("takes Material You's system roles over the default palette", () => { - const variables = { - ...getMobileThemeVariables("t3-code", "light"), - "--color-primary": "#6750A4FF", - "--color-foreground": "#1D1B20FF", - }; - const theme = mobileHtmlRenderTheme({ - themeId: "material-you", - appearance: "light", - variables, - systemColors: true, - platform: "android", - }); - expect(theme.variables["--accent"]).toBe("#6750A4FF"); - expect(theme.variables["--foreground"]).toBe("#1D1B20FF"); - }); -}); diff --git a/apps/mobile/src/lib/http-response.test.ts b/apps/mobile/src/lib/http-response.test.ts index b96a6e9efb7a..6f74ad9df636 100644 --- a/apps/mobile/src/lib/http-response.test.ts +++ b/apps/mobile/src/lib/http-response.test.ts @@ -12,12 +12,4 @@ describe("React Native HTTP responses", () => { expect(result.cookies).toEqual(Cookies.empty); expect(result.status).toBe(400); }); - - it("preserves cookies on platforms that expose Set-Cookie headers", () => { - const result = HttpClientResponse.fromWeb( - HttpClientRequest.get("https://relay.example.test"), - new Response(null, { headers: { "Set-Cookie": "session=abc; HttpOnly" } }), - ); - expect(result.cookies).toEqual(Cookies.fromSetCookie(["session=abc; HttpOnly"])); - }); }); diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 52415e799742..0552d550391c 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,32 +2,16 @@ import { describe, expect, it } from "vite-plus/test"; import { alignListPaneToHinge, - constrainAuxiliaryPaneWidth, - deriveCenteredContentHorizontalPadding, - deriveFileInspectorPaneLayout, deriveLayout, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, isPrimarySidebarPinned, - SPLIT_LAYOUT_MIN_HEIGHT, - SPLIT_LAYOUT_MIN_WIDTH, } from "./layout"; describe("thread work-log text sizing", () => { - it.each([11, 16, 22])( - "keeps exact compact rows at base size %i without OS enlargement", - (baseFontSize) => { - expect(deriveThreadWorkLogSizing({ baseFontSize, fontScale: 1 })).toMatchObject({ - estimatedRowHeight: 28, - fixedRowHeight: 28, - }); - }, - ); - it.each([ { baseFontSize: 16, fontScale: 1.25, estimatedRowHeight: 28 }, - { baseFontSize: 16, fontScale: 2, estimatedRowHeight: 38 }, { baseFontSize: 22, fontScale: 2, estimatedRowHeight: 52 }, ])( "measures accessibility text instead of locking it to the estimate: %j", @@ -53,16 +37,7 @@ describe("thread work-log text sizing", () => { }); describe("deriveThreadFeedInitialContentInset", () => { - it("seeds Android scroll math with the composer overlay estimate", () => { - expect( - deriveThreadFeedInitialContentInset({ - platform: "android", - usesNativeAutomaticInsets: false, - bottomContentInset: 174, - }), - ).toEqual({ bottom: 174 }); - }); - + // regression: #5585 land the first thread open above the composer on Android it("does not double native iOS insets", () => { expect( deriveThreadFeedInitialContentInset({ @@ -74,207 +49,16 @@ describe("deriveThreadFeedInitialContentInset", () => { }); }); -describe("resizable pane constraints", () => { - it("preserves a useful main pane while constraining a trailing pane", () => { - expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); - expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); - expect(constrainAuxiliaryPaneWidth({ preferredWidth: 100, availableWidth: 1_100 })).toBe(260); - }); -}); - -describe("deriveCenteredContentHorizontalPadding", () => { - it("keeps the minimum padding while the viewport fits the reading width", () => { - expect( - deriveCenteredContentHorizontalPadding({ - viewportWidth: 744, - maxContentWidth: 960, - minimumPadding: 20, - }), - ).toBe(20); - }); - - it("centers only the content inside a wider full-width scroll host", () => { - expect( - deriveCenteredContentHorizontalPadding({ - viewportWidth: 1_032, - maxContentWidth: 960, - minimumPadding: 20, - }), - ).toBe(56); - }); - - it("supports unconstrained compact content", () => { - expect( - deriveCenteredContentHorizontalPadding({ - viewportWidth: 430, - maxContentWidth: null, - minimumPadding: 16, - }), - ).toBe(16); - }); -}); - -describe("deriveLayout", () => { - it.each([ - { name: "small iPhone portrait", width: 375, height: 667 }, - { name: "large iPhone portrait", width: 430, height: 932 }, - { name: "small iPhone landscape", width: 667, height: 375 }, - { name: "large iPhone landscape", width: 932, height: 430 }, - { name: "short wide window", width: 1_024, height: 599 }, - { name: "narrow tall window", width: 719, height: 1_024 }, - ])("keeps a $name in the compact shell", ({ width, height }) => { - expect(deriveLayout({ width, height })).toEqual({ - variant: "compact", - usesSplitView: false, - listPaneWidth: null, - shellPadding: 0, - }); - }); - - it.each([ - { name: "small tablet portrait", width: 744, height: 1_133 }, - { name: "tablet landscape", width: 1_024, height: 768 }, - { name: "large resizable window", width: 1_366, height: 1_024 }, - { name: "foldable-sized window", width: 800, height: 700 }, - ])("uses the split shell for a $name", ({ width, height }) => { - expect(deriveLayout({ width, height })).toMatchObject({ - variant: "split", - usesSplitView: true, - }); - }); - - it("switches only after both space requirements are met", () => { - expect( - deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, - ).toBe("split"); - expect( - deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH - 1, height: SPLIT_LAYOUT_MIN_HEIGHT }).variant, - ).toBe("compact"); - expect( - deriveLayout({ width: SPLIT_LAYOUT_MIN_WIDTH, height: SPLIT_LAYOUT_MIN_HEIGHT - 1 }).variant, - ).toBe("compact"); - }); - - it("keeps the sidebar within usable native-column bounds", () => { - expect(deriveLayout({ width: 720, height: 1_000 }).listPaneWidth).toBe(280); - expect(deriveLayout({ width: 1_024, height: 768 }).listPaneWidth).toBe(328); - expect(deriveLayout({ width: 1_600, height: 1_000 }).listPaneWidth).toBe(380); - }); -}); - describe("isPrimarySidebarPinned", () => { - it("lets native split columns collapse the sidebar on the workspace root", () => { - expect( - isPrimarySidebarPinned({ pathname: "/", nativeWorkspace: true, usesSplitView: true }), - ).toBe(false); - }); - + // regression: #20406 sidebar toggle works on iPad and iPhone Duo it("keeps compact native roots pinned so search cannot hide the sidebar for a later split", () => { expect( isPrimarySidebarPinned({ pathname: "/", nativeWorkspace: true, usesSplitView: false }), ).toBe(true); }); - - it("keeps the JS split layout pinned on the workspace root", () => { - expect( - isPrimarySidebarPinned({ pathname: "/", nativeWorkspace: false, usesSplitView: true }), - ).toBe(true); - }); - - it("never pins outside the workspace root", () => { - expect( - isPrimarySidebarPinned({ - pathname: "/threads/a/b", - nativeWorkspace: false, - usesSplitView: true, - }), - ).toBe(false); - }); }); describe("deriveWorkspacePaneLayout", () => { - it("keeps the auxiliary pane out of a standard iPad detail column", () => { - const layout = deriveLayout({ width: 1_194, height: 834 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_194, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - }), - ).toEqual({ - primarySidebarVisible: true, - primarySidebarSuppressedByAuxiliary: false, - contentPaneWidth: 814, - supportsAuxiliaryPane: false, - auxiliaryPaneVisible: false, - auxiliaryPaneWidth: null, - }); - }); - - it("offers an auxiliary pane when maximizing a standard iPad landscape window", () => { - const layout = deriveLayout({ width: 1_194, height: 834 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_194, - primarySidebarPreferredVisible: false, - auxiliaryPanePreferredVisible: true, - }), - ).toEqual({ - primarySidebarVisible: false, - primarySidebarSuppressedByAuxiliary: false, - contentPaneWidth: 1_194, - supportsAuxiliaryPane: true, - auxiliaryPaneVisible: true, - auxiliaryPaneWidth: 320, - }); - }); - - it("prioritizes a trailing file inspector over the thread sidebar at medium widths", () => { - const layout = deriveLayout({ width: 1_024, height: 1_366 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_024, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - auxiliaryPaneRole: "inspector", - }), - ).toEqual({ - primarySidebarVisible: false, - primarySidebarSuppressedByAuxiliary: true, - contentPaneWidth: 1_024, - supportsAuxiliaryPane: true, - auxiliaryPaneVisible: true, - auxiliaryPaneWidth: 260, - }); - }); - - it("keeps threads, content, and the file inspector visible in a large landscape window", () => { - const layout = deriveLayout({ width: 1_366, height: 1_024 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_366, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - auxiliaryPaneRole: "inspector", - }), - ).toEqual({ - primarySidebarVisible: true, - primarySidebarSuppressedByAuxiliary: false, - contentPaneWidth: 986, - supportsAuxiliaryPane: true, - auxiliaryPaneVisible: true, - auxiliaryPaneWidth: 276, - }); - }); - it("keeps an explicitly hidden thread sidebar hidden when the file inspector is visible", () => { const layout = deriveLayout({ width: 1_024, height: 1_366 }); @@ -292,145 +76,11 @@ describe("deriveWorkspacePaneLayout", () => { auxiliaryPaneVisible: true, }); }); - - it("restores the thread sidebar when the file inspector is hidden", () => { - const layout = deriveLayout({ width: 1_024, height: 1_366 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_024, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: false, - auxiliaryPaneRole: "inspector", - }), - ).toMatchObject({ - primarySidebarVisible: true, - primarySidebarSuppressedByAuxiliary: false, - auxiliaryPaneVisible: false, - }); - }); - - it("keeps file navigation on the native stack below the inspector breakpoint", () => { - const layout = deriveLayout({ width: 744, height: 1_133 }); - - expect(deriveFileInspectorPaneLayout({ layout, viewportWidth: 744 })).toEqual({ - supported: false, - width: null, - }); - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 744, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - auxiliaryPaneRole: "inspector", - }), - ).toMatchObject({ - primarySidebarVisible: true, - supportsAuxiliaryPane: false, - auxiliaryPaneVisible: false, - }); - }); - - it("supports three visible columns in a sufficiently large window", () => { - const layout = deriveLayout({ width: 1_366, height: 1_024 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_366, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - }), - ).toMatchObject({ - primarySidebarVisible: true, - contentPaneWidth: 986, - supportsAuxiliaryPane: true, - auxiliaryPaneVisible: true, - auxiliaryPaneWidth: 276, - }); - }); - - it("uses a preferred inspector width when all three panes still fit", () => { - const layout = deriveLayout({ width: 1_366, height: 1_024 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_366, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - auxiliaryPaneRole: "inspector", - auxiliaryPanePreferredWidth: 420, - }), - ).toMatchObject({ - primarySidebarVisible: true, - auxiliaryPaneVisible: true, - auxiliaryPaneWidth: 420, - }); - }); - - it("clamps a preferred supplementary width before squeezing the main pane", () => { - const layout = deriveLayout({ width: 1_366, height: 1_024 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_366, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - auxiliaryPanePreferredWidth: 460, - }).auxiliaryPaneWidth, - ).toBe(426); - }); - - it("respects a hidden auxiliary-pane preference", () => { - const layout = deriveLayout({ width: 1_366, height: 1_024 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1_366, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: false, - }).auxiliaryPaneVisible, - ).toBe(false); - }); - - it("never exposes workspace panes in compact layouts", () => { - const layout = deriveLayout({ width: 430, height: 932 }); - - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: 430, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: true, - }), - ).toMatchObject({ - primarySidebarVisible: false, - supportsAuxiliaryPane: false, - auxiliaryPaneVisible: false, - auxiliaryPaneWidth: null, - }); - }); }); describe("alignListPaneToHinge", () => { const layout = deriveLayout({ width: 852, height: 880 }); - it("ends the list pane at a book-posture hinge after the rail", () => { - expect( - alignListPaneToHinge({ - layout, - hinge: { x: 426, width: 0 }, - viewportWidth: 852, - leadingWidth: 96, - }), - ).toMatchObject({ listPaneWidth: 330, listPaneGap: 0 }); - }); - it("keeps the default split when either side of the hinge would be too narrow", () => { const narrowList = { layout, diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 602d5aada56b..8b6926f11a3f 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -63,20 +63,6 @@ describe("loadLocalAttachmentPreview", () => { ); preview!.dispose(); }); - - it("retains and shares a PDF with its original filename and type", async () => { - const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; - const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); - await preview!.share(new AbortController().signal); - expect(mocks.share).toHaveBeenCalledWith( - expect.objectContaining({ - attachment: { name: "report.pdf", mimeType: "application/pdf" }, - }), - ); - expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); - preview!.dispose(); - expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); - }); it("resolves the current iOS container and releases its playback lease once", async () => { const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); @@ -106,40 +92,4 @@ describe("loadLocalAttachmentPreview", () => { expect(releaseShare).toHaveBeenCalledTimes(1); }, ); - - it("releases a failed share while keeping playback retained", async () => { - mocks.share.mockRejectedValue(new Error("Sharing unavailable")); - const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); - await expect(preview!.share(new AbortController().signal)).rejects.toThrow( - "Sharing unavailable", - ); - expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); - expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); - preview!.dispose(); - }); - - it("releases a load canceled during native module loading", async () => { - const controller = new AbortController(); - const loading = loadLocalAttachmentPreview(attachment, controller.signal); - controller.abort(); - await expect(loading).resolves.toBeNull(); - expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); - expect(mocks.exists).not.toHaveBeenCalled(); - }); - - it("reports missing files and releases their lease", async () => { - mocks.exists.mockReturnValue(false); - await expect( - loadLocalAttachmentPreview(attachment, new AbortController().signal), - ).rejects.toThrow("This attachment is no longer available. Attach the file again."); - expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); - }); - - it("does not start sharing a disposed preview", async () => { - const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); - preview!.dispose(); - await preview!.share(new AbortController().signal); - expect(mocks.share).not.toHaveBeenCalled(); - expect(mocks.retain).toHaveBeenCalledTimes(1); - }); }); diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts index 8b2bb683a357..704a5e7f98b6 100644 --- a/apps/mobile/src/lib/markdownDocument.ts +++ b/apps/mobile/src/lib/markdownDocument.ts @@ -322,3 +322,34 @@ export function markdownWithTableBreaks(markdown: string): string { export function parseMarkdownDocument(markdown: string): Root { return parser.parse(markdownWithHtml(markdown)); } + +const INLINE_CODE_SPAN = /`+([^`\r\n]+)`+/g; + +export function markdownWithInlineFileLinks(markdown: string, fileLink: RegExp): string { + if (![...markdown.matchAll(INLINE_CODE_SPAN)].some(([, code]) => fileLink.test(code!.trim()))) + return markdown; + const edits: Array<{ start: number; end: number; content: string }> = []; + const visit = (node: MarkdownNode) => { + if (node.type === "inlineCode") { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + const escaped = node.value.replace(/[\\[\]&]/g, "\\$&"); + if ( + start !== undefined && + end !== undefined && + fileLink.test(node.value) && + !/[<>]/.test(node.value) + ) + edits.push({ + start, + end, + content: `[${escaped}](<${escaped}>)`, + }); + } else if (node.type !== "link" && node.type !== "linkReference" && "children" in node) + node.children.forEach(visit); + }; + visit(parser.parse(markdown)); + for (const edit of edits.reverse()) + markdown = markdown.slice(0, edit.start) + edit.content + markdown.slice(edit.end); + return markdown; +} diff --git a/apps/mobile/src/lib/markdownHtml.test.ts b/apps/mobile/src/lib/markdownHtml.test.ts index a77a77e9a9cd..e9e1fc219696 100644 --- a/apps/mobile/src/lib/markdownHtml.test.ts +++ b/apps/mobile/src/lib/markdownHtml.test.ts @@ -3,14 +3,6 @@ import { markdownHtmlContent, markdownWithHtml, parseMarkdownDocument } from "./ import { markdownImageSources } from "./markdownImageSources"; describe("HTML in native Markdown", () => { - it("leaves code inside an HTML details block literal", () => { - const content = - '
\nExample\n\n```html\n\n```\n\nUse `` here.\n
'; - const result = markdownHtmlContent(content); - expect(result).toContain('```html\n\n```'); - expect(result).toContain("Use `` here."); - expect(result).not.toContain("
"); - }); it("preserves screenshot URLs and captions from GitHub HTML image embeds", () => { expect( markdownHtmlContent( diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts new file mode 100644 index 000000000000..fe9cddb2938c --- /dev/null +++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ENRICHED_INLINE_FILE_LINK_REGEX } from "./enrichedLinkPresentation"; +import { markdownWithInlineFileLinks, parseMarkdownDocument } from "./markdownDocument"; + +const rewrite = (markdown: string) => + markdownWithInlineFileLinks(markdown, ENRICHED_INLINE_FILE_LINK_REGEX); + +describe("inline code that names a file", () => { + it("becomes a link to the same path", () => { + expect(rewrite("See `src/math.ts:2` and `./README.md`.")).toBe( + "See [src/math.ts:2]() and [./README.md](<./README.md>).", + ); + }); + + it("keeps commands, identifiers and URLs as code", () => { + const markdown = "Run `vp test run`, call `useMemo`, open `https://example.com/a.ts`."; + expect(rewrite(markdown)).toBe(markdown); + }); + + it("leaves fenced code blocks and code already inside a link alone", () => { + const markdown = "```\nsrc/math.ts:2\n```\n\n[`src/math.ts`](src/math.ts)"; + expect(rewrite(markdown)).toBe(markdown); + }); + + it("links to the exact path when it holds backslashes or ampersands", () => { + for (const path of ["C:\\repo\\_private\\file.ts", "./src/a©.ts", "./src/[id].ts"]) { + const paragraph = parseMarkdownDocument(rewrite(`\`${path}\``)).children[0]; + const link = paragraph?.type === "paragraph" ? paragraph.children[0] : undefined; + expect(link?.type === "link" && link.url).toBe(path); + expect( + link?.type === "link" && link.children[0]?.type === "text" && link.children[0].value, + ).toBe(path); + } + }); +}); diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index bf34d7cdc37a..101a064b8e61 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -1,22 +1,17 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "./markdownLinks"; - -describe("resolveMarkdownLinkIcon", () => { - it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { - expect(resolveMarkdownLinkIcon("github.com")).toBe("github"); - expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github"); - expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github"); - expect(resolveMarkdownLinkIcon("github.community")).toBeNull(); - expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull(); - expect(resolveMarkdownLinkIcon("example.com")).toBeNull(); - }); -}); +import { resolveMarkdownLinkPresentation } from "./markdownLinks"; describe("resolveMarkdownLinkPresentation", () => { - it("keeps thread-link hrefs for in-app navigation", () => { - const href = "t3-thread://v1/thread-1"; - expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "link", href }); + it("opens relative directory links as workspace paths", () => { + expect(resolveMarkdownLinkPresentation("src/")).toMatchObject({ + kind: "file", + path: "src/", + }); + expect(resolveMarkdownLinkPresentation("docs/My Folder/")).toMatchObject({ + kind: "file", + path: "docs/My Folder/", + }); }); it("treats protocol-relative media as an external URL, not a filesystem path", () => { @@ -35,14 +30,6 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("extracts external link hosts", () => { - expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ - kind: "external", - href: "https://example.com/docs?q=1", - host: "example.com", - }); - }); - it.each([ ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], @@ -51,7 +38,7 @@ describe("resolveMarkdownLinkPresentation", () => { kind: "file", href, icon: "typescript", - label: "main.ts:42:7", + label: "main.ts · L42:C7", path, line: 42, column: 7, @@ -63,7 +50,7 @@ describe("resolveMarkdownLinkPresentation", () => { kind: "file", href: "apps/mobile/src/index.ts:10", icon: "typescript", - label: "index.ts:10", + label: "index.ts · L10", path: "apps/mobile/src/index.ts", line: 10, }); @@ -83,16 +70,7 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it.each(["md", "html", "xml"])("recognizes a bare spaced .%s filename", (extension) => { - expect( - resolveMarkdownLinkPresentation(`Updated%20cutover%20checklist.${extension}`), - ).toMatchObject({ - kind: "file", - path: `Updated cutover checklist.${extension}`, - label: `Updated cutover checklist.${extension}`, - }); - }); - + // regression: #8098 open agent file links in the file viewer it("recognizes spaced relative paths", () => { expect(resolveMarkdownLinkPresentation("docs/My%20Folder/checklist.xml")).toMatchObject({ kind: "file", @@ -101,35 +79,6 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("extracts line fragments from relative file links", () => { - expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ - kind: "file", - path: "src/main.ts", - line: 18, - column: 2, - label: "main.ts:18:2", - }); - }); - - it("uses the Pierre complete icon mappings", () => { - expect(resolveMarkdownLinkPresentation("src/Button.tsx")).toMatchObject({ - kind: "file", - icon: "react", - }); - expect(resolveMarkdownLinkPresentation("vite.config.ts")).toMatchObject({ - kind: "file", - icon: "vite", - }); - expect(resolveMarkdownLinkPresentation("Dockerfile")).toMatchObject({ - kind: "file", - icon: "docker", - }); - expect(resolveMarkdownLinkPresentation("pnpm-lock.yaml")).toMatchObject({ - kind: "file", - icon: "pnpm", - }); - }); - it("does not style app routes as file links", () => { expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ kind: "link", diff --git a/apps/mobile/src/lib/markdownLinks.ts b/apps/mobile/src/lib/markdownLinks.ts index e18fef748a97..f68add2bf7f9 100644 --- a/apps/mobile/src/lib/markdownLinks.ts +++ b/apps/mobile/src/lib/markdownLinks.ts @@ -1,5 +1,5 @@ import { fileBasename } from "@t3tools/shared/path"; -import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; +import { fileLinkLabel } from "@t3tools/shared/fileLinks"; import { inlineCodeFilePathCandidate, normalizeMarkdownLinkDestination, @@ -273,13 +273,17 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese } } catch {} - const target = parseMarkdownFileLink(normalized); + const target = + parseMarkdownFileLink(normalized) ?? + (/^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+$/.test(normalized) + ? { path: normalized } + : null); if (target) { return { kind: "file", href: normalized, icon: resolveMarkdownFileIcon(target.path), - label: fileBasename(formatFilePathPosition(target)), + label: fileLinkLabel(target, new Map()), path: target.path, ...(target.line ? { line: target.line } : {}), ...(target.column ? { column: target.column } : {}), diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts index ac32f9785eb9..33fec670e41e 100644 --- a/apps/mobile/src/lib/markdownMedia.test.ts +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -50,17 +50,6 @@ describe("resolveMarkdownMediaPreview", () => { }); }); - it("separates a video playback fragment from literal filename characters", () => { - expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({ - kind: "video", - source: { - srcFragment: "#t=2", - resource: { path: "/tmp/clip#one.mp4" }, - actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } }, - }, - }); - }); - it("serves a linked T3 attachment file in place like any other host path", () => { const path = "/home/demo/.t3/userdata/attachments/11111111-1111-4111-8111-111111111111-mp4.mp4"; expect(resolveMarkdownMediaPreview(path, input)).toMatchObject({ @@ -72,17 +61,6 @@ describe("resolveMarkdownMediaPreview", () => { }); }); - it.each(["./image.png", "./clip.mp4", "./report.pdf"])( - "opens relative media through the environment: %s", - (href) => { - const preview = resolveMarkdownMediaPreview(href, input); - expect(preview?.source).toMatchObject({ - environmentId: input.environmentId, - resource: { _tag: "media-file", threadId: input.threadId, path: `/repo/${href}` }, - }); - }, - ); - it("preserves a signed PDF URL and its authored name", () => { const href = "//cdn.example.com/report%20one.pdf?signature=a%2fb#page=2"; expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ @@ -91,22 +69,7 @@ describe("resolveMarkdownMediaPreview", () => { }); }); - it("recognizes a URL-encoded PDF extension and filename without rewriting the URL", () => { - const href = "https://cdn.example.com/report%23one%2520%2Epdf?signature=a%2fb"; - expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ - kind: "pdf", - source: { uri: href, name: "report#one%20.pdf" }, - }); - }); - - it("opens encoded host PDFs without interpreting literal filename characters twice", () => { - expect(resolveMarkdownMediaPreview("/tmp/report%23one.pdf:12#page=2", input)).toMatchObject({ - kind: "pdf", - source: { resource: { path: "/tmp/report#one.pdf" }, srcFragment: "#page=2" }, - }); - }); - - it.each(["./image.png", "./clip.mp4", "./report.pdf"])( + it.each(["./image.png", "./report.pdf"])( "uses the draft workspace when no thread exists: %s", (href) => { expect( @@ -126,18 +89,4 @@ describe("resolveMarkdownMediaPreview", () => { "pdf", ); }); - - it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { - expect( - resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), - ).toMatchObject({ - kind: "video", - source: { - uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2", - actionsSource: { - reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" }, - }, - }, - }); - }); }); diff --git a/apps/mobile/src/lib/markdownTableBreaks.test.ts b/apps/mobile/src/lib/markdownTableBreaks.test.ts index 5e78904d12be..60e41002a035 100644 --- a/apps/mobile/src/lib/markdownTableBreaks.test.ts +++ b/apps/mobile/src/lib/markdownTableBreaks.test.ts @@ -35,13 +35,6 @@ describe("tables right under a line of text", () => { ); }); - it("separates every interrupting table, including ones in list items", () => { - const indented = table.replace(/^/gm, " "); - expect(markdownWithTableBreaks(`Intro\n${table}\n\n- Item\n${indented}`)).toBe( - `Intro\n\n${table}\n\n- Item\n\n${indented}`, - ); - }); - it("leaves Markdown that already renders alone", () => { for (const markdown of [ `Companies on file\n\n${table}`, diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts deleted file mode 100644 index d803a161ad87..000000000000 --- a/apps/mobile/src/lib/materialYouTheme.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; -import type { MaterialYouPalette } from "./materialYouPalette"; -import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme.android"; - -const palette: MaterialYouPalette = { - primary: "#6750A4FF", - onPrimary: "#FFFFFFFF", - primaryContainer: "#EADDFFFF", - onPrimaryContainer: "#21005DFF", - inversePrimary: "#D0BCFFFF", - secondaryContainer: "#E8DEF8FF", - onSecondaryContainer: "#1D192BFF", - tertiary: "#7D5260FF", - tertiaryContainer: "#FFD8E4FF", - onTertiaryContainer: "#31111DFF", - surface: "#FFFBFEFF", - onSurface: "#1C1B1FFF", - onSurfaceVariant: "#49454FFF", - surfaceContainer: "#F3EDF7FF", - surfaceContainerHigh: "#ECE6F0FF", - surfaceContainerHighest: "#E6E0E9FF", - surfaceContainerLow: "#F7F2FAFF", - surfaceContainerLowest: "#FFFFFFFF", - errorContainer: "#F9DEDCFF", - onErrorContainer: "#410E0BFF", - outline: "#79747EFF", - outlineVariant: "#CAC4D0FF", - scrim: "#000000FF", -}; - -describe("Material You system colors", () => { - it.each(["light", "dark"] as const)( - "uses the system surface for the Android frame in %s", - (appearance) => { - const variables = materialYouPaletteToMobileThemeVariables( - palette, - appearance, - getMobileThemeRuntimeVariables("material-you", appearance, "android"), - ); - expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); - }, - ); - - it("overrides the selected theme without mutating its base variables", () => { - const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); - const snapshot = { ...base }; - - const variables = materialYouPaletteToMobileThemeVariables(palette, "dark", base); - - expect(base).toEqual(snapshot); - expect(variables["--color-screen"]).toBe(palette.surface); - expect(variables["--color-thread-canvas"]).toBe(palette.surfaceContainerLow); - expect(variables["--color-thread-selected"]).toBe(palette.surfaceContainer); - expect(variables["--color-header"]).toBe(palette.surfaceContainerHigh); - expect(variables["--color-grouped-card"]).toBe(palette.surfaceContainer); - expect(variables["--color-primary"]).toBe(palette.primary); - expect(variables["--color-placeholder"]).toBe("#1C1B1F9E"); - }); - - it("uses the Messages-style RCS tones for sent messages", () => { - const base = getMobileThemeRuntimeVariables("t3-code", "dark", "android"); - const dark = materialYouPaletteToMobileThemeVariables( - { ...palette, inversePrimary: "#A31D8DFF" }, - "dark", - base, - ); - const light = materialYouPaletteToMobileThemeVariables( - { ...palette, inversePrimary: "#A31D8DFF" }, - "light", - getMobileThemeRuntimeVariables("t3-code", "light", "android"), - ); - - expect(dark["--color-user-bubble"]).toBe("#850073FF"); - expect(dark["--color-user-bubble-foreground"]).toBe("#FFD7EFFF"); - expect(light["--color-user-bubble"]).toBe(palette.primary); - expect(light["--color-user-bubble-foreground"]).toBe(palette.onPrimary); - }); -}); diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts index 6670587b1ca2..c7747cf2f2e8 100644 --- a/apps/mobile/src/lib/mediaActions.test.ts +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -5,7 +5,6 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ @@ -97,112 +96,6 @@ beforeEach(() => { state.copy.mockReset(); }); -it.each([ - [ - "C:/repo/assets/shot.png", - "C:/repo", - String.raw`C:\repo\assets\shot.png`, - String.raw`assets\shot.png`, - ], - [ - String.raw`\\server\share\repo/assets/clip.mp4`, - String.raw`\\server\share\repo`, - String.raw`\\server\share\repo\assets\clip.mp4`, - String.raw`assets\clip.mp4`, - ], -] as const)("copies full and relative media paths for %s", (path, root, full, relative) => { - const media = useMediaActions({ - environmentId, - threadId, - resource: { _tag: "media-file", threadId, path }, - reference: mediaFileReference(path, root), - name: path.split(/[\\/]/).at(-1)!, - mimeType: path.endsWith(".mp4") ? "video/mp4" : "image/png", - }); - for (const [actionId, expected] of [ - ["copy-full-path", full], - ["copy-relative-path", relative], - ] as const) { - const action = media.actions.find(({ id }) => id === actionId); - if (!action) throw new Error(`Missing media action: ${actionId}`); - action.run(); - expect(state.copy).toHaveBeenLastCalledWith(expected); - } - expect(state.refresh).not.toHaveBeenCalled(); - expect(state.download).not.toHaveBeenCalled(); -}); - -it("preserves media URLs when copying", () => { - const url = "https://cdn.test/assets/media?path=C:/repo/media"; - const media = useMediaActions({ - uri: url, - name: "media", - mimeType: "video/mp4", - reference: { kind: "url", url }, - }); - const action = media.actions.find(({ id }) => id === "copy-url"); - if (!action) throw new Error("Copy URL action missing"); - action.run(); - expect(state.copy).toHaveBeenCalledWith(url); - expect(state.refresh).not.toHaveBeenCalled(); -}); - -it.each(["workspace-file", "media-file"] as const)( - "waits for the %s grant before enabling host menu actions", - (_tag) => { - for (const [session, disabled] of [ - [null, true], - [granted, false], - [denied, true], - ] as const) { - if (session === null) state.sessions.delete(environmentId); - else state.sessions.set(environmentId, session); - const media = useMediaActions(hostSource(_tag)); - - expect(media.actions.find(({ id }) => id === "save")?.disabled).toBe(disabled); - expect(media.actions.find(({ id }) => id === "open-file")?.disabled).toBe(disabled); - const copyPath = media.actions.find(({ id }) => id === "copy-full-path")!; - expect(copyPath.disabled).not.toBe(true); - copyPath.run(); - } - expect(state.copy).toHaveBeenCalledTimes(3); - expect(state.refresh).not.toHaveBeenCalled(); - expect(state.download).not.toHaveBeenCalled(); - }, -); - -it("keeps nonhost menu actions available with pending or denied host grants", () => { - const sources: MediaActionsSource[] = [ - { uri: "https://cdn.test/image.png", name: "image.png", mimeType: "image/png" }, - { uri: "file:///device/image.png", name: "image.png", mimeType: "image/png" }, - { - environmentId, - resource: { _tag: "attachment", attachmentId: "upload" }, - name: "image.png", - mimeType: "image/png", - }, - { - attachment: { - id: "draft", - type: "file", - fileUri: "file:///device/image.png", - name: "image.png", - mimeType: "image/png", - sizeBytes: 1, - }, - name: "image.png", - mimeType: "image/png", - }, - ]; - for (const session of [null, denied]) { - if (session === null) state.sessions.delete(environmentId); - else state.sessions.set(environmentId, session); - for (const source of sources) { - expect(useMediaActions(source).actions.find(({ id }) => id === "save")?.disabled).toBe(false); - } - } -}); - it.each(["workspace-file", "media-file"] as const)( "blocks denied %s sharing and file opening while preserving path copying", async (_tag) => { @@ -276,18 +169,6 @@ it("lets an unresolved grant be authorized by an explicit server request", async expect(state.download).toHaveBeenCalledOnce(); }); -it("shares uploaded attachments without host filesystem access", async () => { - await useMediaActions({ - environmentId, - resource: { _tag: "attachment", attachmentId: "upload" }, - name: "image.png", - mimeType: "image/png", - }).share(); - - expect(state.refresh).toHaveBeenCalledOnce(); - expect(state.download).toHaveBeenCalledOnce(); -}); - it.each(["https://cdn.test/image.png", "file:///device/image.png"])( "shares direct media without a host grant: %s", async (uri) => { @@ -297,21 +178,3 @@ it.each(["https://cdn.test/image.png", "file:///device/image.png"])( expect(uri.startsWith("file:") ? state.shareLocal : state.download).toHaveBeenCalledOnce(); }, ); - -it("shares device draft attachments without a host grant", async () => { - await useMediaActions({ - attachment: { - id: "draft", - type: "file", - fileUri: "file:///device/image.png", - name: "image.png", - mimeType: "image/png", - sizeBytes: 1, - }, - name: "image.png", - mimeType: "image/png", - }).share(); - - expect(state.refresh).not.toHaveBeenCalled(); - expect(state.shareDraft).toHaveBeenCalledOnce(); -}); diff --git a/apps/mobile/src/lib/menu-action-colors.test.ts b/apps/mobile/src/lib/menu-action-colors.test.ts deleted file mode 100644 index a6ee03e58c42..000000000000 --- a/apps/mobile/src/lib/menu-action-colors.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { MenuAction } from "@react-native-menu/menu"; -import { describe, expect, it } from "vite-plus/test"; - -import { withMenuActionIconColors } from "./menu-action-colors"; - -describe("withMenuActionIconColors", () => { - it.each(["#111111", "#eeeeee"])( - "gives icons a visible color at every menu depth for the %s theme", - (icon) => { - const actions: MenuAction[] = [ - { id: "photos", title: "Photos", image: "photo" }, - { - title: "Thread", - subactions: [ - { - title: "Pinned thread", - image: "pin", - subactions: [{ title: "Move up", image: "arrow.up" }], - }, - ], - }, - ]; - - const result = withMenuActionIconColors(actions, { icon, destructiveIcon: "#ff0000" }); - - expect(result[0]?.imageColor).toBe(icon); - expect(result[1]).not.toHaveProperty("imageColor"); - expect(result[1]?.subactions?.[0]?.imageColor).toBe(icon); - expect(result[1]?.subactions?.[0]?.subactions?.[0]?.imageColor).toBe(icon); - expect(actions[0]).not.toHaveProperty("imageColor"); - expect(actions[1]?.subactions?.[0]).not.toHaveProperty("imageColor"); - }, - ); - - it("uses the destructive color while retaining action state and attributes", () => { - const action: MenuAction = { - id: "delete", - title: "Delete", - image: "trash", - state: "off", - attributes: { destructive: true, disabled: true }, - }; - - expect( - withMenuActionIconColors([action], { - icon: "#111111", - destructiveIcon: "#cc0000", - }), - ).toEqual([{ ...action, imageColor: "#cc0000" }]); - }); - - it.each(["#123456", "transparent", 0])("preserves explicit icon color %s", (imageColor) => { - const action: MenuAction = { - title: "Delete", - image: "trash", - imageColor, - attributes: { destructive: true }, - }; - - expect( - withMenuActionIconColors([action], { - icon: "#111111", - destructiveIcon: "#cc0000", - }), - ).toEqual([action]); - }); -}); diff --git a/apps/mobile/src/lib/menu-placement.test.ts b/apps/mobile/src/lib/menu-placement.test.ts deleted file mode 100644 index 35721d910f5e..000000000000 --- a/apps/mobile/src/lib/menu-placement.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { deriveMenuHorizontalPlacement } from "./menu-placement"; - -const base = { anchorWidth: 48, overlayWidth: 852, desiredWidth: 224, margin: 12 }; - -describe("deriveMenuHorizontalPlacement", () => { - it("opens toward the roomier side of the whole screen without a hinge", () => { - expect(deriveMenuHorizontalPlacement({ ...base, anchorX: 300, hinge: null })).toEqual({ - left: 300, - width: 224, - }); - }); - - it("keeps a menu on its anchor's side of a creased hinge", () => { - // The filter button sits near the crease, so the menu right-aligns to it instead of crossing. - expect( - deriveMenuHorizontalPlacement({ ...base, anchorX: 340, hinge: { x: 426, width: 0 } }), - ).toEqual({ left: 164, width: 224 }); - expect( - deriveMenuHorizontalPlacement({ ...base, anchorX: 430, hinge: { x: 426, width: 0 } }), - ).toEqual({ left: 438, width: 224 }); - }); -}); diff --git a/apps/mobile/src/lib/microphonePriority.test.ts b/apps/mobile/src/lib/microphonePriority.test.ts index 1d037fceab88..fff3659057f6 100644 --- a/apps/mobile/src/lib/microphonePriority.test.ts +++ b/apps/mobile/src/lib/microphonePriority.test.ts @@ -18,15 +18,6 @@ const usb = { uid: "usb", name: "Shure MV7", type: "USBAudio" }; const keys = (order: ReadonlyArray) => order.map(microphoneEntryKey); describe("microphoneOrder", () => { - it("lists every kind once, CarPlay below the built-in microphone by default", () => { - expect(keys(microphoneOrder())).toEqual([ - "kind:wired", - "kind:bluetooth", - "kind:builtIn", - "kind:carPlay", - ]); - }); - it("keeps a saved order and files missing kinds after their devices", () => { const saved: MicrophoneEntry[] = [ { kind: "builtIn" }, @@ -85,8 +76,4 @@ describe("preferredMicrophoneInput", () => { expect(preferredMicrophoneInput([airPods, carPlay], order)).toBe(airPods); expect(preferredMicrophoneInput([airPods, iPhone], order)).toBe(iPhone); }); - - it("leaves inputs of unknown kinds to iOS", () => { - expect(preferredMicrophoneInput([{ uid: "x", name: "x", type: "Virtual" }], order)).toBeNull(); - }); }); diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index d201a1e5ae7e..31fc6f83f11c 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -15,15 +15,11 @@ import { import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; import { - createMobileThemePairPatch, createMobileThemeSelectionPatch, createMobileThemeVariables, DEFAULT_MOBILE_THEME_ID, flattenThemeColor, - getMobileThemePreviewColors, getMobileThemeVariables, - normalizeMobileThemeId, - normalizeMobileThemeMode, resolveMobileThemeIds, themeColorWithAlpha, themeColorToNativeColor, @@ -71,14 +67,6 @@ describe("mobile themes", () => { ); }); - it("shares all built-in desktop palettes", () => { - expect(BUILT_IN_THEMES.map((theme) => theme.id)).toEqual(BUILT_IN_THEME_IDS); - for (const themeId of BUILT_IN_THEME_IDS) { - expect(getMobileThemeVariables(themeId, "light")["--color-screen"]).toMatch(/^#/); - expect(getMobileThemeVariables(themeId, "dark")["--color-screen"]).toMatch(/^#/); - } - }); - it.each(MOBILE_THEME_IDS)("uses the web color roles for %s in both appearances", (themeId) => { for (const appearance of ["light", "dark"] as const) { const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId); @@ -171,15 +159,6 @@ describe("mobile themes", () => { } }); - it("applies palette overrides on top of the selected built-in theme", () => { - const variables = getMobileThemeVariables("ocean", "dark", { - "--color-primary": "#123456", - }); - - expect(variables["--color-primary"]).toBe("#123456"); - expect(variables["--color-screen"]).toMatch(/^#/); - }); - it.each(MOBILE_THEME_IDS)("separates %s filled groups from their sheets", (themeId) => { for (const appearance of ["light", "dark"] as const) { const generated = readGeneratedMobileThemeVariables( @@ -223,145 +202,6 @@ describe("mobile themes", () => { } }); - it.each(["light", "dark"] as const)( - "separates default settings groups from their %s background", - (appearance) => { - const variables = getMobileThemeVariables("t3-code", appearance); - expect( - contrastRatio(variables["--color-grouped-card"], variables["--color-sheet-solid"]), - ).toBeGreaterThanOrEqual(1.06); - expect(variables["--color-grouped-card"]).not.toBe(variables["--color-card"]); - for (const platform of ["ios", "android"]) { - const runtime = getMobileThemeRuntimeVariables("t3-code", appearance, platform); - const sidebar = flattenThemeColor(runtime["--color-drawer"], runtime["--color-screen"]); - const chrome = flattenThemeColor( - runtime[platform === "android" ? "--color-header" : "--color-drawer"], - runtime["--color-screen"], - ); - if (platform === "ios") { - expect(relativeLuminance(sidebar)).toBeLessThan( - relativeLuminance(runtime["--color-thread-canvas"]), - ); - } - expect(contrastRatio(chrome, runtime["--color-screen"])).toBeGreaterThanOrEqual(1.06); - const foregroundRoles = - platform === "android" - ? (["--color-header-foreground", "--color-foreground-muted"] as const) - : (["--color-drawer-foreground", "--color-drawer-foreground-muted"] as const); - for (const role of foregroundRoles) { - expect(contrastRatio(runtime[role], chrome)).toBeGreaterThanOrEqual(4.5); - } - } - }, - ); - - it("keeps T3 Chat light grouped cards distinct from the sheet", () => { - const variables = getMobileThemeVariables("t3-chat", "light"); - const group = variables["--color-grouped-card"]; - expect(contrastRatio(group, variables["--color-sheet-solid"])).toBeGreaterThanOrEqual(1.1); - for (const role of ["--color-foreground", "--color-foreground-muted"] as const) { - expect(contrastRatio(variables[role], group)).toBeGreaterThanOrEqual(4.5); - } - }); - - it.each(MOBILE_THEME_IDS)("keeps %s prominent actions readable and themed", (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const variables = getMobileThemeVariables(themeId, appearance); - const fill = variables["--color-prominent-action"]; - expect( - contrastRatio(variables["--color-prominent-action-foreground"], fill), - ).toBeGreaterThanOrEqual(4.5); - expect(fill).toBe( - variables[ - themeId === "t3-chat" && appearance === "dark" - ? "--color-primary-foreground" - : themeId === DEFAULT_MOBILE_THEME_ID || themeId === "t3-chat" - ? "--color-foreground" - : "--color-primary" - ], - ); - } - }); - - it.each(["grove", "ocean", "ember", "iris"] as const)( - "keeps %s grouped cards and floating controls visibly tinted", - (themeId) => { - const saturation = (hex: string) => { - const channels = hex - .slice(1) - .match(/.{2}/g)! - .map((value) => parseInt(value, 16)); - return (Math.max(...channels) - Math.min(...channels)) / Math.max(...channels); - }; - for (const appearance of ["light", "dark"] as const) { - const variables = getMobileThemeVariables(themeId, appearance); - const sheet = variables["--color-sheet-solid"]; - const group = variables["--color-grouped-card"]; - expect(contrastRatio(group, sheet)).toBeGreaterThanOrEqual(1.06); - for (const role of ["--color-composer-surface", "--color-glass-fallback"] as const) { - const surface = flattenThemeColor(variables[role], sheet); - expect(contrastRatio(surface, sheet)).toBeGreaterThanOrEqual(1.06); - expect(contrastRatio(variables["--color-placeholder"], surface)).toBeGreaterThanOrEqual( - 4.5, - ); - if (appearance === "dark") { - expect(saturation(surface)).toBeGreaterThanOrEqual(saturation(sheet)); - } - } - if (appearance === "dark") { - expect(saturation(group)).toBeGreaterThanOrEqual(saturation(sheet)); - } - } - }, - ); - - it.each(["light", "dark"] as const)( - "slightly strengthens default %s messages and separates fallback materials", - (appearance) => { - const variables = getMobileThemeVariables("t3-code", appearance); - const desktop = - appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS; - const bubbleContrast = contrastRatio( - variables["--color-user-bubble"], - variables["--color-screen"], - ); - expect(bubbleContrast).toBeGreaterThan(contrastRatio(desktop.messageSurface, desktop.canvas)); - expect(bubbleContrast).toBeLessThan(1.2); - for (const role of ["--color-composer-surface", "--color-glass-fallback"] as const) { - const surface = flattenThemeColor(variables[role], variables["--color-screen"]); - expect(contrastRatio(surface, variables["--color-screen"])).toBeGreaterThanOrEqual(1.06); - for (const foreground of [ - "--color-foreground", - "--color-placeholder", - "--color-primary-text", - ] as const) { - expect(contrastRatio(variables[foreground], surface)).toBeGreaterThanOrEqual(4.5); - } - } - }, - ); - - it("uses the same preview roles and standard artwork as desktop", () => { - expect(getMobileThemePreviewColors(DEFAULT_MOBILE_THEME_ID, "light")).toEqual({ - canvas: "#fcfcfc", - accent: "#f4f4f5", - messageAction: "#4f46e5", - }); - const desktopOcean = BUILT_IN_THEMES.find((theme) => theme.id === "ocean")!; - expect(getMobileThemePreviewColors("ocean", "light")).toEqual({ - canvas: themeColorToNativeColor(desktopOcean.colors.canvas), - accent: themeColorToNativeColor(desktopOcean.colors.accent), - messageAction: themeColorToNativeColor(desktopOcean.colors.messageAction), - }); - }); - - it("normalizes persisted theme preferences", () => { - expect(normalizeMobileThemeId("ocean")).toBe("ocean"); - expect(normalizeMobileThemeId("missing-theme")).toBe(DEFAULT_MOBILE_THEME_ID); - expect(normalizeMobileThemeMode("dark")).toBe("dark"); - expect(normalizeMobileThemeMode("sepia")).toBe("system"); - }); - it("migrates one theme choice to both appearances and preserves independent choices", () => { expect(resolveMobileThemeIds({ themeId: "grove" })).toEqual({ light: "grove", @@ -390,52 +230,12 @@ describe("mobile themes", () => { }); }); - it("changes both appearance themes from the card action", () => { - expect(createMobileThemePairPatch("ember")).toEqual({ - lightThemeId: "ember", - darkThemeId: "ember", - themeId: "ember", - }); - }); - it("converts OKLCH colors to React Native sRGB ColorValues", () => { expect(themeColorToNativeColor("oklch(1 0 0)")).toBe("#ffffff"); expect(themeColorToNativeColor("oklch(0 0 0)")).toBe("#000000"); expect(themeColorToNativeColor("#123456")).toBe("#123456"); }); - it("changes native palette color opacity for fades", () => { - expect(themeColorWithAlpha("#123456", 0)).toBe("rgba(18, 52, 86, 0)"); - expect(themeColorWithAlpha("rgba(18, 52, 86, 0.98)", 0)).toBe("rgba(18, 52, 86, 0)"); - }); - - it("maps semantic palette roles onto every mobile color variable", () => { - const variables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); - expect(variables["--color-sheet-solid"]).toBe( - themeColorToNativeColor(T3_CHAT_THEME.colors.chrome), - ); - expect(variables["--color-warning"]).toBe( - themeColorToNativeColor(T3_CHAT_THEME.colors.warningSurface), - ); - expect(variables["--color-warning-foreground"]).toBe( - themeColorToNativeColor(T3_CHAT_THEME.colors.warningForeground), - ); - expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); - expect(variables["--color-primary-shadow"]).toBe("#000000"); - expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); - expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); - expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); - }); - - it("keeps every built-in shadow and backdrop black-based in dark mode", () => { - for (const themeId of BUILT_IN_THEME_IDS) { - const variables = getMobileThemeVariables(themeId, "dark"); - expect(variables["--color-primary-shadow"]).toBe("#000000"); - expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); - expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); - } - }); - it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { for (const themeId of BUILT_IN_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { @@ -501,21 +301,3 @@ describe("mobile themes", () => { } }); }); - -describe("flattenThemeColor", () => { - it("composites a translucent border over its surface", async () => { - const { flattenThemeColor } = await import("./mobileTheme"); - expect(flattenThemeColor("rgba(255, 255, 255, 0.06)", "#171717")).toBe("#252525"); - expect(flattenThemeColor("rgba(0, 0, 0, 0.08)", "#ffffff")).toBe("#ebebeb"); - }); - - it("leaves an already opaque colour alone", async () => { - const { flattenThemeColor } = await import("./mobileTheme"); - expect(flattenThemeColor("#171717", "#ffffff")).toBe("#171717"); - }); - - it("treats a colour with no alpha as fully opaque", async () => { - const { flattenThemeColor } = await import("./mobileTheme"); - expect(flattenThemeColor("rgb(255, 0, 0)", "#000000")).toBe("#ff0000"); - }); -}); diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts index ad678d1f8815..b65a5e9391f6 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.test.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { createMobileThemeRuntimeOperations, - getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "./mobileThemeRuntime"; @@ -13,30 +12,6 @@ const initialState: MobileThemeRuntimeState = { }; describe("mobileThemeRuntime", () => { - it("keeps the default palette on Uniwind's built-in appearance themes", () => { - expect(getMobileUniwindThemeName("t3-code", "light")).toBe("light"); - expect(getMobileUniwindThemeName("t3-code", "dark")).toBe("dark"); - }); - - it("maps custom palettes and appearances to registered themes", () => { - expect(getMobileUniwindThemeName("t3-chat", "dark")).toBe("t3-chat-dark"); - }); - - it("hydrates text variables and clears the native appearance override", () => { - const operations = createMobileThemeRuntimeOperations(null, initialState); - const variableOperations = operations.filter( - (operation) => operation.kind === "update-text-variables", - ); - - expect(variableOperations).toHaveLength(12); - expect(variableOperations.at(-1)?.themeName).toBe("iris-dark"); - expect(operations.at(-1)).toEqual({ - kind: "set-appearance-mode", - appearance: "light", - themeMode: "system", - }); - }); - it("lets system appearance changes flow through the root ScopedTheme only", () => { const operations = createMobileThemeRuntimeOperations(initialState, { ...initialState, @@ -45,38 +20,4 @@ describe("mobileThemeRuntime", () => { expect(operations).toEqual([]); }); - - it("updates native appearance once when the selected mode changes", () => { - const operations = createMobileThemeRuntimeOperations(initialState, { - ...initialState, - themeAppearance: "dark", - themeMode: "dark", - }); - - expect(operations).toEqual([ - { - kind: "set-appearance-mode", - appearance: "dark", - themeMode: "dark", - }, - ]); - }); - - it("updates text variables for every theme without switching palettes", () => { - const operations = createMobileThemeRuntimeOperations(initialState, { - ...initialState, - baseFontSize: 18, - }); - - expect(operations).toHaveLength(12); - expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true); - expect(operations.at(-1)).toMatchObject({ - kind: "update-text-variables", - themeName: "iris-dark", - }); - }); - - it("does no native work when persistence echoes an already-applied state", () => { - expect(createMobileThemeRuntimeOperations(initialState, initialState)).toEqual([]); - }); }); diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts deleted file mode 100644 index c3e275f3dc0c..000000000000 --- a/apps/mobile/src/lib/mobileThemeVariables.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; -import { getMobileThemeVariables, MOBILE_THEME_IDS, themeColorWithAlpha } from "./mobileTheme"; -import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; - -describe("mobile theme runtime variables", () => { - it("matches the standard base palette to the generated stylesheet", () => { - expect(getMobileThemeRuntimeVariables("t3-code", "light", "web")).toEqual( - readDefaultMobileThemeVariables("light"), - ); - expect(getMobileThemeRuntimeVariables("t3-code", "dark", "web")).toEqual( - readDefaultMobileThemeVariables("dark"), - ); - }); - - it("uses the same shared palette source as generated custom themes", () => { - expect(getMobileThemeRuntimeVariables("ocean", "light", "ios")).toEqual( - getMobileThemeVariables("ocean", "light"), - ); - expect(getMobileThemeRuntimeVariables("iris", "dark", "ios")).toEqual( - getMobileThemeVariables("iris", "dark"), - ); - }); - - it.each(MOBILE_THEME_IDS)( - "uses a screen-colored %s sidebar beneath an opaque Android frame", - (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const base = getMobileThemeVariables( - themeId === "material-you" ? "t3-code" : themeId, - appearance, - ); - const android = getMobileThemeRuntimeVariables(themeId, appearance, "android"); - expect(android).toEqual({ - ...base, - "--color-header": themeColorWithAlpha( - base[ - themeId === "t3-code" || themeId === "material-you" - ? "--color-row-hover" - : "--color-drawer" - ], - 1, - ), - "--color-header-foreground": base["--color-drawer-foreground"], - "--color-drawer": base["--color-screen"], - }); - expect(android["--color-header"]).toMatch(/^rgba\(\d+, \d+, \d+, 1\)$/); - } - }, - ); - - it.each(["t3-code", "material-you"] as const)( - "keeps the %s default Material frame distinct in both appearances", - (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android"); - expect(variables["--color-header"]).toBe( - appearance === "light" ? "rgba(244, 244, 245, 1)" : "rgba(20, 20, 20, 1)", - ); - for (const pane of ["--color-screen", "--color-sheet-solid", "--color-drawer"] as const) { - expect(variables["--color-header"]).not.toBe(themeColorWithAlpha(variables[pane], 1)); - } - } - }, - ); - - it.each(["t3-code", "material-you"] as const)( - "adapts %s iPad chrome without reversing the dark desktop hierarchy", - (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const ios = getMobileThemeRuntimeVariables(themeId, appearance, "ios"); - const android = getMobileThemeRuntimeVariables(themeId, appearance, "android"); - if (appearance === "light") { - expect(ios["--color-drawer"]).toBe(android["--color-header"]); - expect(ios["--color-header"]).toBe(android["--color-header"]); - expect(ios["--color-header-foreground"]).toBe(ios["--color-drawer-foreground"]); - } else { - expect(ios).toEqual(getMobileThemeVariables("t3-code", appearance)); - expect(ios["--color-drawer"]).toBe("#000000"); - expect(ios["--color-thread-canvas"]).toBe("#0a0a0a"); - } - expect(themeColorWithAlpha(ios["--color-thread-hover"], 1)).not.toBe( - themeColorWithAlpha(ios["--color-drawer"], 1), - ); - expect(themeColorWithAlpha(ios["--color-thread-hover"], 1)).not.toBe( - themeColorWithAlpha(ios["--color-thread-selected"], 1), - ); - for (const role of [ - "--color-screen", - "--color-thread-canvas", - "--color-card", - "--color-composer-surface", - "--color-grouped-card", - ] as const) { - expect(ios[role]).toBe(android[role]); - } - } - }, - ); -}); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 779dc1f764fb..11b9b0603be8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -22,85 +22,6 @@ import { } from "./modelOptions"; describe("mobile model options", () => { - it("groups models by provider and flags legacy entries", () => { - const config = { - providers: [ - { - instanceId: "codex", - driver: "codex", - displayName: "Codex", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { - slug: "gpt-5.6-sol", - name: "GPT-5.6 Sol", - isCustom: false, - capabilities: null, - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - isLegacy: true, - capabilities: null, - }, - ], - }, - ], - } as unknown as ServerConfig; - - expect(groupByProvider(buildModelOptions(config, null, undefined, null))).toMatchObject([ - { - providerKey: "codex", - providerLabel: "Codex", - models: [ - { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", subtitle: "", isLegacy: false }, - { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, - ], - }, - ]); - }); - - it("carries configured ACP identity into model and provider catalogs", () => { - const iconUrl = "https://cdn.agentclientprotocol.com/registry/v1/latest/antigravity-acp.svg"; - const config = { - providers: [ - { - instanceId: "acpRegistry_antigravity", - driver: "acpRegistry", - displayName: "Antigravity", - iconUrl, - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [ - { - slug: "default", - name: "Default", - isCustom: false, - capabilities: null, - }, - ], - }, - ], - } as unknown as ServerConfig; - - const [group] = groupByProvider(buildModelOptions(config, null, undefined, null)); - - expect(group).toMatchObject({ - providerKey: "acpRegistry_antigravity", - providerLabel: "Antigravity", - models: [ - { - providerDriver: "acpRegistry", - providerIconUrl: iconUrl, - }, - ], - }); - }); - it("distinguishes same-name OpenCode models without changing their routing", () => { const sources = [ { id: "anthropic", label: "Anthropic" }, @@ -245,6 +166,127 @@ describe("mobile model options", () => { ); }); + it("keeps accounts in the continuation group and excludes other providers", () => { + const config = { + providers: [ + { instanceId: "codex", driver: "codex", continuation: { groupKey: "shared" } }, + { instanceId: "codex_work", driver: "codex", continuation: { groupKey: "shared" } }, + { instanceId: "codex_other", driver: "codex", continuation: { groupKey: "other" } }, + { instanceId: "claude", driver: "claudeAgent", continuation: { groupKey: "shared" } }, + ].map((provider) => ({ + ...provider, + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "test", name: "Test", capabilities: null }], + })), + } as unknown as ServerConfig; + expect( + buildModelOptions(config, null, ProviderInstanceId.make("codex"), null).map( + (option) => option.providerKey, + ), + ).toEqual(["codex", "codex_work"]); + }); + + it("matches the web driver fallback without moving unknown Antigravity history", () => { + const config = { + providers: [ + { instanceId: "codex", driver: "codex" }, + { instanceId: "codex_work", driver: "codex" }, + { instanceId: "google", driver: "antigravity" }, + { instanceId: "google_work", driver: "antigravity" }, + ].map((provider) => ({ + ...provider, + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "test", name: "Test", capabilities: null }], + })), + } as unknown as ServerConfig; + expect( + buildModelOptions(config, null, ProviderInstanceId.make("codex"), null).map( + (option) => option.providerKey, + ), + ).toEqual(["codex", "codex_work"]); + expect( + buildModelOptions(config, null, ProviderInstanceId.make("google"), null).map( + (option) => option.providerKey, + ), + ).toEqual(["google"]); + }); + + it("carries account badges into catalog and saved fallback models", () => { + const config = { + providers: [ + { instanceId: "codex", driver: "codex", displayName: "Personal" }, + { instanceId: "codex_work", driver: "codex", displayName: "Work", accentColor: "#123456" }, + ].map((provider) => ({ + ...provider, + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "test", name: "Test", capabilities: null }], + })), + } as unknown as ServerConfig; + const selection = { instanceId: ProviderInstanceId.make("codex_work"), model: "saved" }; + const options = buildModelOptions(config, selection, undefined, null); + expect(options[0]?.providerBadge).toEqual({ displayName: "Personal", showBadge: true }); + expect(options[1]?.providerBadge).toEqual({ + displayName: "Work", + accentColor: "#123456", + showBadge: true, + }); + expect(options[2]?.providerBadge).toEqual(options[1]?.providerBadge); + expect( + buildModelOptions( + { ...config, providers: config.providers.slice(0, 1) }, + null, + undefined, + null, + )[0]?.providerBadge?.showBadge, + ).toBe(false); + }); + + it("only badges ACP accounts that share the same agent", () => { + const config = { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + acp_one: { driver: "acpRegistry", enabled: true, config: { agentId: "one" } }, + acp_two: { driver: "acpRegistry", enabled: true, config: { agentId: "two" } }, + }, + }, + providers: ["acp_one", "acp_two"].map((instanceId) => ({ + instanceId, + driver: "acpRegistry", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "test", name: "Test", capabilities: null }], + })), + } as unknown as ServerConfig; + expect( + buildModelOptions(config, null, undefined, null).map( + (option) => option.providerBadge?.showBadge, + ), + ).toEqual([false, false]); + const shared = { + ...config, + settings: { + ...config.settings, + providerInstances: { + ...config.settings.providerInstances, + acp_two: config.settings.providerInstances[ProviderInstanceId.make("acp_one")]!, + }, + }, + }; + expect( + buildModelOptions(shared, null, undefined, null).map( + (option) => option.providerBadge?.showBadge, + ), + ).toEqual([true, true]); + }); + it.each(["disabled", "unavailable", "missing"] as const)( "retains the selected %s provider's fallback in a filtered catalog", (state) => { @@ -358,9 +400,7 @@ describe("mobile model options", () => { it.each([ ["disabled", { enabled: false }], - ["uninstalled", { installed: false }], ["signed out", { auth: { status: "unauthenticated" } }], - ["unavailable", { availability: "unavailable" }], ] as const)("keeps a %s provider's selection and known model details", (_state, update) => { const unavailableConfig = { ...config, @@ -552,26 +592,6 @@ describe("mobile model options", () => { } as unknown as ServerConfig; } - it("words a project's own turn-off apart from missing setup", () => { - const settings: ServerSettings = { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, - }, - }; - const selection = { instanceId, model: "model-a" }; - - expect( - describeUnavailableModelSelection(configWith(settings), selection, projectId).title, - ).toBe("Model turned off for this project"); - expect(describeUnavailableModelSelection(configWith(settings), selection, null).title).toBe( - "Antigravity model unavailable", - ); - }); - it("refuses to send a non-Antigravity model the project turned off", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, @@ -602,34 +622,6 @@ describe("mobile model options", () => { expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toEqual([]); }); - it("shows an instance the project enables even though the machine disables it", () => { - const settings: ServerSettings = { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }, - }; - - expect(buildModelOptions(configWith(settings), null, undefined, projectId)).toHaveLength(1); - }); - - it("falls back to the machine value with no project in scope", () => { - const settings: ServerSettings = { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, - }, - }; - - expect(buildModelOptions(configWith(settings), null, undefined, null)).toHaveLength(1); - }); - it("hides a machine-disabled instance for a project with no override, even though another project's keep-alive left it running", () => { const otherProjectId = ProjectId.make("project-b"); const settings: ServerSettings = { @@ -679,26 +671,6 @@ describe("mobile model options", () => { expect(options[0]).toMatchObject({ selection: fallback, isUnavailable: true }); }); - it("rejects a project-disabled instance's selection via resolveSelectableModelSelection", () => { - const settings: ServerSettings = { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, - }, - }; - const selection = { instanceId, model: "model-a" }; - - expect( - resolveSelectableModelSelection(configWith(settings), selection, projectId), - ).toBeNull(); - expect(resolveSelectableModelSelection(configWith(settings), selection, null)).toBe( - selection, - ); - }); - it("rejects a project-disabled antigravity instance's selection instead of keeping it", () => { const antigravityInstanceId = ProviderInstanceId.make("antigravity_work"); const config = { @@ -763,6 +735,7 @@ describe("synchronized mobile model visibility", () => { }, providers: [provider, other].map((instanceId) => ({ instanceId, + continuation: { groupKey: instanceId }, driver: "codex", enabled: true, installed: true, @@ -817,6 +790,7 @@ describe("synchronized mobile model visibility", () => { ).toEqual(["second", "first", "custom"]); }); + // regression: #16816 sync model favorites and visibility across clients it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { expect( buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 82a5fa9088b1..42a5ec76bce1 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,3 +1,8 @@ +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; import type { MenuAction } from "@react-native-menu/menu"; import type { ModelCapabilities, @@ -21,6 +26,11 @@ export type ModelOption = { readonly providerDriver: string; readonly supportedRuntimeModes?: ReadonlyArray; readonly providerIconUrl?: string | undefined; + readonly providerBadge?: { + readonly displayName: string; + readonly accentColor?: string; + readonly showBadge: boolean; + }; readonly isDefault: boolean; readonly isLegacy: boolean; readonly isUnavailable?: boolean; @@ -236,10 +246,61 @@ export function buildModelOptions( includeHidden = false, ): ReadonlyArray { const options = new Map(); + const lockedProvider = config?.providers.find( + (provider) => provider.instanceId === providerInstanceId, + ); + const matchesLockedProvider = (provider: T3ServerConfig["providers"][number]) => { + if (providerInstanceId === undefined || provider.instanceId === providerInstanceId) return true; + if (!lockedProvider || provider.driver !== lockedProvider.driver) return false; + const groupKey = lockedProvider.continuation?.groupKey; + if (groupKey) return provider.continuation?.groupKey === groupKey; + return provider.driver !== "antigravity"; + }; + const registryAgentId = (instanceId: ModelSelection["instanceId"]) => { + const configValue = config?.settings?.providerInstances[instanceId]?.config; + if (configValue === null || typeof configValue !== "object") return undefined; + const agentId = "agentId" in configValue ? configValue.agentId : undefined; + return "source" in configValue && configValue.source === "local" + ? undefined + : typeof agentId === "string" + ? agentId.trim() || undefined + : undefined; + }; + const badgeEntries = (config?.providers ?? []).map((provider) => ({ + driverKind: provider.driver, + enabled: provider.enabled, + acpRegistryAgentId: registryAgentId(provider.instanceId), + })); + const providerBadgeFor = (instanceId: ModelSelection["instanceId"]) => { + const snapshot = config?.providers.find((provider) => provider.instanceId === instanceId); + const instance = config?.settings?.providerInstances[instanceId]; + const driver = snapshot?.driver ?? instance?.driver; + if (!driver) return undefined; + const accentColor = normalizeProviderAccentColor( + snapshot?.accentColor ?? instance?.accentColor, + ); + return { + displayName: resolveProviderInstanceDisplayName({ + instanceId, + driver, + displayName: snapshot?.displayName ?? instance?.displayName, + }), + ...(accentColor ? { accentColor } : {}), + showBadge: shouldShowInstanceBadge( + { + driverKind: driver, + accentColor, + acpRegistryAgentId: registryAgentId(instanceId), + enabled: snapshot?.enabled ?? instance?.enabled ?? false, + }, + badgeEntries, + ), + }; + }; for (const provider of config?.providers ?? []) { if ( - (providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) || + !matchesLockedProvider(provider) || !isProviderEnabledForProject(config, provider, projectId) || !provider.installed || provider.auth.status === "unauthenticated" || @@ -249,6 +310,7 @@ export function buildModelOptions( } const providerLabel = providerDisplayLabel(provider); + const providerBadge = providerBadgeFor(provider.instanceId); const updateRequired = provider.updateRequiredModels?.length ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } : undefined; @@ -269,6 +331,7 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, + providerBadge, ...(provider.supportedRuntimeModes === undefined ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), @@ -333,6 +396,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver, + providerBadge: providerBadgeFor(fallbackModelSelection.instanceId), isDefault: false, isLegacy: model?.isLegacy === true, ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) || diff --git a/apps/mobile/src/lib/native-glass-capability.test.ts b/apps/mobile/src/lib/native-glass-capability.test.ts deleted file mode 100644 index 43f865c77c30..000000000000 --- a/apps/mobile/src/lib/native-glass-capability.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { supportsNativeLiquidGlass } from "./native-glass-capability"; - -describe("supportsNativeLiquidGlass", () => { - it("uses native liquid glass when iOS reports the capability", () => { - expect(supportsNativeLiquidGlass("ios", true)).toBe(true); - }); - - it("keeps pre-glass iOS on the solid fallback", () => { - expect(supportsNativeLiquidGlass("ios", false)).toBe(false); - }); - - it("does not enable iOS liquid-glass layout behavior on other platforms", () => { - expect(supportsNativeLiquidGlass("android", true)).toBe(false); - expect(supportsNativeLiquidGlass("web", true)).toBe(false); - }); -}); diff --git a/apps/mobile/src/lib/nativeComposerTheme.test.ts b/apps/mobile/src/lib/nativeComposerTheme.test.ts index 785293b96420..19bcd2ff0086 100644 --- a/apps/mobile/src/lib/nativeComposerTheme.test.ts +++ b/apps/mobile/src/lib/nativeComposerTheme.test.ts @@ -1,32 +1,16 @@ import { describe, expect, it } from "vite-plus/test"; -import { MOBILE_THEME_IDS } from "@t3tools/shared/themePalettes"; import { getMobileThemeVariables } from "./mobileTheme"; import { createNativeComposerTheme } from "./nativeComposerTheme"; describe("native composer colors", () => { - it.each(MOBILE_THEME_IDS)("delivers parsable colors for %s in both appearances", (themeId) => { - for (const appearance of ["light", "dark"] as const) { - const variables = getMobileThemeVariables(themeId, appearance); - const theme = createNativeComposerTheme(variables); - for (const color of Object.values(theme)) expect(color).toMatch(/^#[\da-f]{6}$/i); - expect(theme.text).toBe(variables["--color-foreground"]); - expect(theme.skillBorder).not.toBe(theme.skillBackground); - } - }); - it("flattens Material You alpha colors over the composer rather than falling back to defaults", () => { const theme = createNativeComposerTheme({ ...getMobileThemeVariables("t3-code", "light"), "--color-screen": "#ffffffff", "--color-composer-surface": "#ffffffff", - "--color-subtle": "#0000000d", "--color-placeholder": "#0000009e", - "--color-inline-skill-background": "#ffffffff", - "--color-inline-skill-border": "#00000080", }); - expect(theme.chipBackground).toBe("#f2f2f2"); expect(theme.placeholder).toBe("#616161"); - expect(theme.skillBorder).toBe("#7f7f7f"); }); }); diff --git a/apps/mobile/src/lib/nativeComposerTheme.ts b/apps/mobile/src/lib/nativeComposerTheme.ts index 80e252f0d234..c740a990075f 100644 --- a/apps/mobile/src/lib/nativeComposerTheme.ts +++ b/apps/mobile/src/lib/nativeComposerTheme.ts @@ -3,17 +3,8 @@ import { flattenThemeColor, type MobileThemeVariables } from "./mobileTheme"; /** Native chip parsers need opaque hex instead of CSS rgba or platform-specific alpha order. */ export function createNativeComposerTheme(theme: MobileThemeVariables) { const surface = flattenThemeColor(theme["--color-composer-surface"], theme["--color-screen"]); - const chipBackground = flattenThemeColor(theme["--color-subtle"], surface); - const skillBackground = flattenThemeColor(theme["--color-inline-skill-background"], surface); return { text: flattenThemeColor(theme["--color-foreground"], surface), placeholder: flattenThemeColor(theme["--color-placeholder"], surface), - chipBackground, - chipBorder: flattenThemeColor(theme["--color-border"], chipBackground), - chipText: flattenThemeColor(theme["--color-foreground"], chipBackground), - skillBackground, - skillBorder: flattenThemeColor(theme["--color-inline-skill-border"], skillBackground), - skillText: flattenThemeColor(theme["--color-inline-skill-foreground"], skillBackground), - fileTint: flattenThemeColor(theme["--color-icon-muted"], chipBackground), }; } diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts index 8a96ae533664..1dd1ff7db002 100644 --- a/apps/mobile/src/lib/openExternalUrl.test.ts +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -18,44 +18,6 @@ afterEach(() => { }); describe("tryOpenExternalUrl", () => { - it("opens supported URLs", async () => { - openURL.mockResolvedValue(undefined); - - await expect( - tryOpenExternalUrl("https://github.com/pingdotgg/t3code", "pull-request"), - ).resolves.toBe(true); - }); - - it("logs stable URL context without exposing the opening failure", async () => { - const cause = new Error("browser-unavailable-secret-sentinel"); - openURL.mockRejectedValue(cause); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await expect( - tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"), - ).resolves.toBe(false); - - expect(consoleError).toHaveBeenCalledTimes(1); - const [message, attributes] = consoleError.mock.calls[0] ?? []; - expect(message).toBe("Failed to open pull-request URL with the https scheme."); - expect(attributes).toEqual( - expect.objectContaining({ - _tag: "ExternalUrlOpenError", - target: "pull-request", - scheme: "https", - host: "github.com", - stack: expect.stringContaining("ExternalUrlOpenError"), - }), - ); - expect(attributes).not.toHaveProperty("url"); - expect(attributes).not.toHaveProperty("cause"); - const diagnosticText = [message, ...Object.values(attributes as Record)] - .map(String) - .join("\n"); - expect(diagnosticText).not.toContain("token=secret"); - expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); - }); - it("keeps provider sign-in URLs unchanged and out of failure logs", async () => { const url = "https://accounts.google.com/o/oauth2/v2/auth?state=private-state&code_challenge=private-challenge&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2F"; diff --git a/apps/mobile/src/lib/pdfPreview.test.ts b/apps/mobile/src/lib/pdfPreview.test.ts index 7e5559d3ef75..12fbff001434 100644 --- a/apps/mobile/src/lib/pdfPreview.test.ts +++ b/apps/mobile/src/lib/pdfPreview.test.ts @@ -16,7 +16,7 @@ vi.mock("expo-file-system", () => ({ FileMode: { ReadOnly: "r" }, })); -import { isPdfPreviewUri, pdfInitialPage, pdfLinkUrl, preparePdfPreview } from "./pdfPreview"; +import { isPdfPreviewUri, pdfLinkUrl, preparePdfPreview } from "./pdfPreview"; beforeEach(() => { vi.resetAllMocks(); @@ -53,20 +53,6 @@ describe("PDF navigation", () => { expect(isPdfPreviewUri("https://environment.test/index.html?file=syllabus.pdf")).toBe(false); expect(isPdfPreviewUri("invalid")).toBe(false); }); - - it("keeps valid page destinations and rejects invalid page numbers", () => { - expect(pdfInitialPage("https://environment.test/a.pdf#page=8&zoom=100")).toBe(8); - for (const fragment of [ - "", - "#page=0", - "#page=-2", - "#page=1.5", - "#page=NaN", - "#page=Infinity", - ]) { - expect(pdfInitialPage(`file:///a.pdf${fragment}`)).toBe(1); - } - }); }); describe("preparing native PDFs", () => { @@ -94,15 +80,6 @@ describe("preparing native PDFs", () => { expect(mocks.dispose).toHaveBeenCalledOnce(); }); - it("closes and removes the cached file when reading fails", async () => { - mocks.read.mockRejectedValue(new Error("read failed")); - await expect( - preparePdfPreview("https://remote.test/a.pdf", new AbortController().signal), - ).rejects.toThrow("read failed"); - expect(mocks.close).toHaveBeenCalledOnce(); - expect(mocks.dispose).toHaveBeenCalledOnce(); - }); - it("opens local files without downloading or deleting the caller's file", async () => { const result = await preparePdfPreview( "file:///draft.pdf#page=3", @@ -123,12 +100,4 @@ describe("preparing native PDFs", () => { expect(await preparePdfPreview("https://remote.test/a.pdf", controller.signal)).toBeNull(); expect(mocks.dispose).toHaveBeenCalledOnce(); }); - - it("does no work for an already dismissed preview", async () => { - const controller = new AbortController(); - controller.abort(); - expect(await preparePdfPreview("https://remote.test/a.pdf", controller.signal)).toBeNull(); - expect(mocks.download).not.toHaveBeenCalled(); - expect(mocks.read).not.toHaveBeenCalled(); - }); }); diff --git a/apps/mobile/src/lib/projectFaviconRequests.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts index 540a45473926..78f815f9b504 100644 --- a/apps/mobile/src/lib/projectFaviconRequests.test.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -30,20 +30,6 @@ describe("project favicon requests", () => { endExpiredRequest(); }); - it("evicts the URL that actually failed", () => { - const cacheKey = "environment-1:/workspace:v2-favicon.svg"; - const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; - const request = createProjectFaviconRequest(cacheKey, faviconUrl); - const endRequest = beginProjectFaviconRequest(request); - - markProjectFaviconLoaded(request); - - expect(markProjectFaviconFailed(request)).toBe(true); - expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); - - endRequest(); - }); - it("does not supersede a request until the next request begins", () => { const cacheKey = "environment-1:/workspace:v3-favicon.svg"; const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; @@ -59,14 +45,6 @@ describe("project favicon requests", () => { endCommittedRequest(); }); - it("requires a cache key before creating a URL-bearing request", () => { - const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; - const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; - - expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); - expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); - }); - it("restores the remaining active URL when a newer request ends", () => { const cacheKey = "environment-1:/workspace:v4-favicon.svg"; const firstRequest = createProjectFaviconRequest( diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index c71e3f7f4f80..b0f231c7efa8 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -1,35 +1,14 @@ -import { - EnvironmentId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { serializeComposerMessageForServer } from "./composerContext"; import { buildProjectThreadStartTurnInput, - deriveThreadTitleFromPrompt, type ProjectThreadStartTurnSpec, } from "./projectThreadStartTurn"; describe("project thread title", () => { - it("normalizes draft and queued titles while keeping the mobile length limit", () => { - expect( - deriveThreadTitleFromPrompt( - "![photo.png](t3-context://v1/image/photo) Fix [src/app.ts](t3-context://v1/mention/source) so the sidebar remains readable when a thread starts with an image", - ), - ).toBe("Fix src/app.ts so the sidebar remains readable when a thread starts w..."); - }); - - it("keeps ordinary titles and the empty-prompt fallback", () => { - expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); - expect(deriveThreadTitleFromPrompt(" \n ")).toBe("New thread"); - }); - it.each([ { type: "image" as const, @@ -90,86 +69,4 @@ describe("project thread title", () => { expect(relaunched.message.text).toBe(wire.text); expect(relaunched.message.attachments).toEqual(uploadedAttachments); }); - - it.each([ - { - comment: undefined, - title: "Keep `cache[key]` & shared. Retry!", - }, - { - comment: 'Why "shared"?', - title: "Keep `cache[key]` & shared. Retry! Commen...", - }, - ])("uses readable titles and intact links with comment $comment", ({ comment, title }) => { - const quoteText = "Keep `cache[key]` & shared.\n Retry!"; - const text = serializeAssistantCitation({ - version: 1, - environmentId: EnvironmentId.make("source-environment"), - threadId: ThreadId.make("source-thread"), - messageId: MessageId.make("source-message"), - text: quoteText, - ...(comment === undefined ? {} : { comment }), - start: 0, - end: quoteText.length, - prefix: "", - suffix: "", - }); - const input = buildProjectThreadStartTurnInput({ - projectId: ProjectId.make("project"), - projectCwd: "/workspace", - threadId: "new-thread", - commandId: "command", - messageId: "message", - createdAt: "2026-09-01T00:00:00Z", - text, - uploadedAttachments: [], - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, - runtimeMode: "full-access", - interactionMode: "default", - workspaceMode: "local", - branch: null, - worktreePath: null, - startFromOrigin: false, - worktreeBranchName: "unused", - }); - - expect(input.titleSeed).toBe(title); - expect(input.bootstrap.createThread.title).toBe(input.titleSeed); - expect(input.message.text).toBe(text); - }); -}); - -describe("new thread on an existing branch", () => { - it.each([null, "/worktrees/existing"])( - "reuses the selected workspace %s without preparing a new worktree", - (worktreePath) => { - const input = buildProjectThreadStartTurnInput({ - projectId: ProjectId.make("project"), - projectCwd: "/workspace", - threadId: "new-thread", - commandId: "command", - messageId: "message", - createdAt: "2026-09-06T00:00:00Z", - text: "Start fresh", - uploadedAttachments: [], - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, - runtimeMode: "full-access", - interactionMode: "default", - workspaceMode: "local", - branch: "feature/existing", - worktreePath, - startFromOrigin: false, - worktreeBranchName: "unused", - }); - - expect(input.bootstrap.createThread).toMatchObject({ - projectId: "project", - branch: "feature/existing", - worktreePath, - }); - expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); - expect(input.bootstrap).not.toHaveProperty("runSetupScript"); - expect(input.threadId).toBe("new-thread"); - }, - ); }); diff --git a/apps/mobile/src/lib/protectedData.test.ts b/apps/mobile/src/lib/protectedData.test.ts index 2949ce9447f0..d8a7fd7f025e 100644 --- a/apps/mobile/src/lib/protectedData.test.ts +++ b/apps/mobile/src/lib/protectedData.test.ts @@ -80,16 +80,7 @@ afterEach(async () => { describe("protected storage startup", () => { it.each([ { launch: "background", state: "background", checkRejects: false }, - { launch: "inactive", state: "inactive", checkRejects: false }, - { launch: "unknown", state: "unknown", checkRejects: false }, - { launch: "unset", state: null, checkRejects: false }, { launch: "background with a failed native check", state: "background", checkRejects: true }, - { - launch: "background without a native check", - state: "background", - nativeCheckSupported: false, - }, - { launch: "unknown without a native check", state: "unknown", nativeCheckSupported: false }, { launch: "unset without a native check", state: null, nativeCheckSupported: false }, ])( "keeps a locked $launch launch usable after activation without rebuilding its runtime", @@ -127,39 +118,4 @@ describe("protected storage startup", () => { ); }, ); - - it.each([ - { - launch: "unlocked background", - state: "background", - platform: "ios", - nativeCheckSupported: true, - }, - { launch: "active foreground", state: "active", platform: "ios", nativeCheckSupported: true }, - { - launch: "Android background", - state: "background", - platform: "android", - nativeCheckSupported: false, - }, - { - launch: "Android with a native check", - state: "background", - platform: "android", - nativeCheckSupported: true, - }, - ])("starts storage on $launch launch without waiting for activation", async (scenario) => { - mocks.appState = scenario.state; - mocks.platform = scenario.platform; - mocks.nativeCheckSupported = scenario.nativeCheckSupported; - mocks.isProtectedDataAvailable.mockResolvedValue(scenario.platform === "ios"); - ({ runtime } = await import("./runtime")); - - expect(await runtime.runPromise(Effect.succeed("ready"))).toBe("ready"); - expect(mocks.isProtectedDataAvailable).toHaveBeenCalledTimes( - scenario.platform === "ios" && scenario.state === "background" ? 1 : 0, - ); - expect(mocks.readStorage).toHaveBeenCalledOnce(); - expect(mocks.listeners.size).toBe(0); - }); }); diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts deleted file mode 100644 index 0935a181151b..000000000000 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type { ModelCapabilities } from "@t3tools/contracts"; - -import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; - -const CODEX_CAPABILITIES: ModelCapabilities = { - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [ - { id: "medium", label: "Medium", isDefault: true }, - { id: "high", label: "High" }, - ], - currentValue: "medium", - }, - { - id: "serviceTier", - label: "Service Tier", - type: "select", - options: [ - { id: "default", label: "Standard", isDefault: true }, - { id: "priority", label: "Fast" }, - ], - currentValue: "default", - }, - ], -}; - -describe("mobile provider options", () => { - it("updates generic select options without knowing provider-specific ids", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect( - applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "priority" }), - ).toEqual([ - { id: "reasoningEffort", value: "medium" }, - { id: "serviceTier", value: "priority" }, - ]); - expect( - applyProviderOptionSelection(descriptors, { id: "serviceTier", value: "turbo" }), - ).toBeNull(); - expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); - }); - - it("updates generic boolean options", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: { - optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], - }, - selections: undefined, - }); - - expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ - { id: "fastMode", value: true }, - ]); - }); -}); diff --git a/apps/mobile/src/lib/reserved-regions.test.ts b/apps/mobile/src/lib/reserved-regions.test.ts index e1da0fa04842..b95ec5ea8ad1 100644 --- a/apps/mobile/src/lib/reserved-regions.test.ts +++ b/apps/mobile/src/lib/reserved-regions.test.ts @@ -12,14 +12,6 @@ const innerDisplay: NativeLayoutMetrics = { }; describe("bottom controls around reserved regions", () => { - it("respects asymmetric side controls without reserving the bottom twice", () => { - expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); - }); - - it("leaves ordinary non-native clients unchanged", () => { - expect(deriveBottomControlInsets(null)).toEqual({ left: 0, right: 0, bottom: 0 }); - }); - it("keeps the editor entirely on one side of a book fold", () => { const insets = deriveBottomControlInsets({ ...innerDisplay, @@ -49,65 +41,9 @@ describe("bottom controls around reserved regions", () => { }), ).toEqual({ left: 68, right: 72, bottom: 0 }); }); - - it("restores the full editor width when the device opens flat", () => { - const folded = { - ...innerDisplay, - reservedRegions: [{ kind: "division" as const, x: 456, y: 0, width: 48, height: 560 }], - }; - expect(deriveBottomControlInsets(folded)).not.toEqual(deriveBottomControlInsets(innerDisplay)); - expect(deriveBottomControlInsets(innerDisplay)).toEqual({ left: 8, right: 72, bottom: 0 }); - }); }); describe("Duo workspace sizing", () => { - it("shows both columns on a short regular-width inner display", () => { - const layout = deriveLayout({ ...innerDisplay, nativeMetrics: innerDisplay }); - expect(layout.usesSplitView).toBe(true); - expect( - deriveWorkspacePaneLayout({ - layout, - viewportWidth: innerDisplay.width, - primarySidebarPreferredVisible: true, - auxiliaryPanePreferredVisible: false, - }).contentPaneWidth, - ).toBeGreaterThan(320); - }); - - it("keeps an ordinary phone compact when it rotates", () => { - expect(deriveLayout({ width: 852, height: 393 }).usesSplitView).toBe(false); - for (const [width, height] of [ - [932, 430], - [650, 500], - ]) { - const metrics: NativeLayoutMetrics = { - ...innerDisplay, - width: width!, - height: height!, - verticalBarEdge: "none", - safeArea: { top: 0, bottom: 20, left: 59, right: 59 }, - }; - expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); - const cameraMetrics = { - ...metrics, - reservedRegions: [{ kind: "occlusion" as const, x: 0, y: 0, width: 60, height: 60 }], - }; - expect(deriveLayout({ ...cameraMetrics, nativeMetrics: cameraMetrics }).usesSplitView).toBe( - false, - ); - } - expect( - deriveLayout({ - width: 960, - height: 560, - nativeMetrics: { - ...innerDisplay, - horizontalSizeClass: "compact", - }, - }).usesSplitView, - ).toBe(false); - }); - it("shows both columns on the unfolded inner display in portrait", () => { // Reported by the iOS 27.1 Duo simulator: no side bar or fold, only the hinge. const portrait: NativeLayoutMetrics = { @@ -127,29 +63,6 @@ describe("Duo workspace sizing", () => { ); }); - it("keeps the folded front screen compact in both orientations", () => { - for (const [width, height] of [ - [466, 678], - [678, 466], - ]) { - const folded: NativeLayoutMetrics = { - width: width!, - height: height!, - hasHinge: true, - horizontalSizeClass: "compact", - verticalBarEdge: "right", - safeArea: { top: 0, bottom: 34, left: 0, right: 84 }, - reservedRegions: [], - }; - expect(deriveLayout({ ...folded, nativeMetrics: folded }).usesSplitView).toBe(false); - } - }); - - it("requires enough safe width for both panes in a multitasking window", () => { - const metrics = { ...innerDisplay, width: 660 }; - expect(deriveLayout({ ...metrics, nativeMetrics: metrics }).usesSplitView).toBe(false); - }); - it("aligns the panes to the book fold and accounts for the reserved gap", () => { const metrics = { ...innerDisplay, diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index af2b0b732981..e5dc872abebf 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -99,12 +99,7 @@ vi.mock("react-native", () => ({ }, })); -import { - loadPreferences, - loadSavedConnections, - saveConnection, - savePreferencesPatch, -} from "../persistence/imperative"; +import { loadPreferences, saveConnection, savePreferencesPatch } from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -136,45 +131,6 @@ describe("mobile connection storage", () => { }); }); - it("loads relay-managed connection metadata without a cached access token", async () => { - await saveConnection(managedConnection); - - await expect(loadSavedConnections()).resolves.toEqual([ - toStableSavedRemoteConnection(managedConnection), - ]); - }); - - it("preserves secure-storage read failures with operation and key context", async () => { - const cause = new Error("keychain unavailable"); - mocks.getItemAsync.mockRejectedValueOnce(cause); - - await expect(loadSavedConnections()).rejects.toMatchObject({ - _tag: "MobileSecureStorageError", - operation: "read", - key: "t3code.connections", - cause, - message: "Mobile secure storage operation read failed for key t3code.connections.", - }); - }); - - it("logs structured decode failures before using the empty fallback", async () => { - await mocks.setItemAsync("t3code.connections", "{"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - await expect(loadSavedConnections()).resolves.toEqual([]); - expect(warn).toHaveBeenCalledWith( - "[mobile-storage] ignored invalid JSON", - expect.objectContaining({ - _tag: "MobileStorageDecodeError", - key: "t3code.connections", - cause: expect.any(SyntaxError), - message: "Failed to decode mobile storage value for key t3code.connections.", - }), - ); - - warn.mockRestore(); - }); - it("loads legacy preferences when SQLite is unavailable", async () => { mocks.setDatabaseFailures(true, true); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); @@ -182,50 +138,6 @@ describe("mobile connection storage", () => { await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); - it("persists independent light and dark theme choices", async () => { - mocks.setPreferencesJson( - JSON.stringify({ - themeId: "grove", - lightThemeId: "iris", - darkThemeId: "ocean", - themeMode: "system", - }), - 10, - ); - - await expect(loadPreferences()).resolves.toEqual({ - themeId: "grove", - lightThemeId: "iris", - darkThemeId: "ocean", - themeMode: "system", - }); - }); - - it("persists Material You independently for each appearance", async () => { - const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; - await savePreferencesPatch(themes); - await expect(loadPreferences()).resolves.toEqual(themes); - await savePreferencesPatch({ lightThemeId: "t3-chat" }); - await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); - }); - - it.each([true, false])("drops the removed Android layout preference (%s)", async (enabled) => { - mocks.setPreferencesJson( - JSON.stringify({ - materialYouStyleLayoutEnabled: enabled, - lightThemeId: "t3-chat", - }), - 10, - ); - await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat" }); - }); - - it("drops the removed theme transition preference", async () => { - mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); - - await expect(loadPreferences()).resolves.toEqual({}); - }); - it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); @@ -237,66 +149,6 @@ describe("mobile connection storage", () => { expect(fallback.updatedAt).toEqual(expect.any(Number)); }); - it("persists thread list shelf preferences", async () => { - await expect( - savePreferencesPatch({ - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }), - ).resolves.toEqual({ - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }); - - await expect(loadPreferences()).resolves.toEqual({ - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }); - expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ - threadListWorkingShelfExpanded: true, - workingShelfEnabled: true, - }); - }); - - it("drops legacy and invalid thread list shelf expansion preferences", async () => { - mocks.setPreferencesJson( - JSON.stringify({ - baseFontSize: 17, - threadListV2SettledShelfExpanded: true, - threadListV2SnoozedShelfExpanded: true, - }), - 10, - ); - - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); - }); - - it("keeps valid list filters and drops malformed ones", async () => { - const threadListFilters = { environmentId: "environment-1", projectKey: null }; - const pullRequestListFilters = { - environmentId: null, - controls: { - "": { - state: "all", - involvement: "reviewing", - filters: { labels: [["size:S"]] }, - sort: "oldest", - }, - }, - }; - mocks.setPreferencesJson(JSON.stringify({ threadListFilters, pullRequestListFilters }), 10); - await expect(loadPreferences()).resolves.toEqual({ threadListFilters, pullRequestListFilters }); - - mocks.setPreferencesJson( - JSON.stringify({ - threadListFilters: { environmentId: 1, projectKey: null }, - pullRequestListFilters: { environmentId: null, controls: { "": { sort: "newest" } } }, - }), - 11, - ); - await expect(loadPreferences()).resolves.toEqual({}); - }); - it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( @@ -327,21 +179,6 @@ describe("mobile connection storage", () => { expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); }); - it("ignores an invalid fallback even when it has a newer timestamp", async () => { - mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); - await mocks.setItemAsync( - "t3code.preferences.fallback", - JSON.stringify({ payload: "{", updatedAt: 40 }), - ); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); - expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); - expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); - - warn.mockRestore(); - }); - it("keeps SQLite authoritative when stale legacy preferences remain", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 78f80270f784..8693cbbb6d6d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,38 +1,32 @@ import { - ContextHandoffId, MessageId, CheckpointId, + CheckpointRef, CheckpointScopeId, RuntimeRequestId, NodeId, - PlanId, ProviderInstanceId, ProviderDriverKind, ProviderThreadId, ProviderTurnId, RunId, RunAttemptId, - ScheduledTaskId, ThreadId, TurnItemId, type OrchestrationV2RunAttempt, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; -import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; -import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation"; +import { deriveThreadCheckpointSummaries } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { getReviewSectionIdForCheckpoint } from "../features/review/reviewModel"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { workEntryRowLabel, - isContextHandoffActivityGroup, buildThreadFeed, createThreadFeedCache, deriveThreadFeedPresentation, - threadFeedActivityIsVisible, - threadFeedRunIsUnsettled, - type ThreadFeedActivity, type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, @@ -60,171 +54,26 @@ it("keeps historical plan detail accessible from its paged turn item", () => { entry.type === "activity-group" ? entry.activities : [], )[0]; expect(activity?.detail).toBe("Full historical plan text"); - expect(activity?.getFullDetail()).toContain("Full historical plan text"); -}); - -it("shows only the structured path in expanded mobile read details", () => { - const item: OrchestrationV2TurnItem = { - ...base("read-detail", "2026-06-20T00:00:03.000Z", 2), - type: "dynamic_tool", - toolName: "Read", - title: "Read src/env.ts", - input: { path: "src/env.ts" }, - output: "---\nname: env\n---\nsecret content", - }; - const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - )[0]; - - expect(activity?.getFullDetail()).toBe("src/env.ts"); - expect(activity?.canExpand).toBe(true); - expect(activity?.getCopyText()).not.toContain("secret content"); - expect(activity?.getFullDetail()).not.toContain("sourceThreadId"); - - const withoutPath = buildThreadFeed([ - projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0), - ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]; - expect(withoutPath?.getFullDetail()).toBeNull(); - expect(withoutPath?.canExpand).toBe(false); -}); - -it("expands a T3 Code notice to the prompt the agent got", () => { - const item = { - ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), - type: "notification", - source: { kind: "system" }, - outcome: "updated", - summary: "T3 Code restarted and resumed this turn", - detail: "Continue where you left off.", - } as OrchestrationV2TurnItem; - const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - - expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); - expect(activity?.canExpand).toBe(true); - expect(activity?.getFullDetail()).toBe("Continue where you left off."); -}); - -it("labels file searches with the adapter title and its search target", () => { - const item: OrchestrationV2TurnItem = { - ...base("file-search", "2026-06-20T00:00:03.000Z", 2), - type: "file_search", - title: "Searched TODO in web", - pattern: "TODO", - }; - const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - )[0]; - - expect(activity?.summary).toBe("Searched TODO in web"); - expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web"); -}); - -describe("work rows relative to the thread workspace", () => { - const root = "/repo/wt"; - const activitiesOf = (feed: ReturnType) => - feed.flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); - - it("drops a cd to the workspace and relativizes command paths", () => { - const item: OrchestrationV2TurnItem = { - ...base("workspace-command", "2026-06-20T00:00:02.000Z", 1), - type: "command_execution", - input: `/bin/zsh -lc 'cd ${root} && bun run test ${root}/src'`, - output: "ok", - exitCode: 0, - }; - const [activity] = activitiesOf(buildThreadFeed([projected(item, 0)], { workspaceRoot: root })); - - expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("bun run test src"); - // Copying keeps the command exactly as the agent ran it. - expect(activity?.getCopyText()).toContain(`cd ${root} && bun run test ${root}/src`); - - const [unscoped] = activitiesOf(buildThreadFeed([projected(item, 0)])); - expect(unscoped ? workEntryRowLabel(unscoped.workEntry) : null).toBe( - `cd ${root} && bun run test ${root}/src`, - ); - }); - - it("rebuilds cached path labels when the thread workspace changes", () => { - const item: OrchestrationV2TurnItem = { - ...base("cached-workspace-command", "2026-06-20T00:00:02.000Z", 1), - type: "command_execution", - input: `/bin/zsh -lc 'cd ${root} && bun run test ${root}/src'`, - output: "ok", - exitCode: 0, - }; - const rows = [projected(item, 0)]; - const cache = createThreadFeedCache(); - const [scoped] = activitiesOf(buildThreadFeed(rows, { workspaceRoot: root, cache })); - const [unscoped] = activitiesOf(buildThreadFeed(rows, { cache })); - expect(scoped ? workEntryRowLabel(scoped.workEntry) : null).toBe("bun run test src"); - expect(unscoped ? workEntryRowLabel(unscoped.workEntry) : null).toBe( - `cd ${root} && bun run test ${root}/src`, - ); - }); - - it("relativizes file tool targets but not prose rows", () => { - const change: OrchestrationV2TurnItem = { - ...base("workspace-change", "2026-06-20T00:00:02.000Z", 1), - type: "file_change", - fileName: `${root}/src/index.ts`, - }; - const read: OrchestrationV2TurnItem = { - ...base("workspace-read", "2026-06-20T00:00:03.000Z", 2), - type: "dynamic_tool", - toolName: "Read", - input: { file_path: `${root}/docs/guide.md` }, - }; - const notice: OrchestrationV2TurnItem = { - ...base("workspace-notice", "2026-06-20T00:00:04.000Z", 3), - type: "system_notice", - message: `Config reloaded from ${root}/t3.json`, - }; - const activities = activitiesOf( - buildThreadFeed([projected(change, 0), projected(read, 1), projected(notice, 2)], { - workspaceRoot: root, - }), - ); - - expect(activities[0]?.summary).toBe("Changed src/index.ts"); - expect(activities[0]?.workEntry.changedFiles).toEqual(["src/index.ts"]); - expect(workEntryRowLabel(activities[1]!.workEntry, false, root)).toBe("Read docs/guide.md"); - expect(activities[2]?.summary).toBe(`Config reloaded from ${root}/t3.json`); - }); -}); - -it("keeps approval prompts rather than presenting them as tool work", () => { - const approval = ( - id: string, - requestKind: "file-read" | "command" | "file-change", - ordinal: number, - ) => - ({ - ...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal), - type: "approval_request", - requestId: RuntimeRequestId.make(`request-${id}`), - requestKind, - prompt: `Allow ${requestKind}?`, - }) satisfies OrchestrationV2TurnItem; - const feed = buildThreadFeed([ - projected(approval("approve-read", "file-read", 1), 0), - projected(approval("approve-command", "command", 2), 1), - projected(approval("approve-edit", "file-change", 3), 2), - ]); - const activities = feed.flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], + expect(activity?.getFullDetail()).toBe("Full historical plan text"); + expect(activity?.getCopyText()).toBe("Full historical plan text\n"); + expect(activity?.prominent).toBe(true); + const presented = deriveThreadFeedPresentation( + buildThreadFeed([ + projected(userMessage(), 0), + projected(command(), 1), + projected(item, 2), + projected(assistantMessage(), 3), + ]), + null, + new Set(), ); - - expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([ - "Allow file-read?", - "Allow command?", - "Allow file-change?", + expect(presented.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "activity-group", + "message", ]); - expect(activities[0]?.canExpand).toBe(true); - expect( - summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary, - ).not.toMatch(/Ran|changed/); + expect(presented[2]).toMatchObject({ activities: [{ projectedItem: { item } }] }); }); function base(id: string, updatedAt: string, ordinal: number) { @@ -318,17 +167,174 @@ describe("buildThreadFeed", () => { expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message"); }); - it("does not create a work group for a message and checkpoint", () => { - const checkpoint: OrchestrationV2TurnItem = { - ...base("checkpoint", "2026-06-20T00:00:04.000Z", 3), - type: "checkpoint", - checkpointId: CheckpointId.make("checkpoint"), - scopeId: CheckpointScopeId.make("scope"), - files: [], - }; - const feed = buildThreadFeed([projected(assistantMessage(), 0), projected(checkpoint, 1)]); - expect(feed.map((entry) => entry.type)).toEqual(["message"]); - }); + it.each([false, true])( + "keeps answered questions outside settled work (attachments only: %s)", + (attachmentsOnly) => { + const requestId = RuntimeRequestId.make("answered-question"); + const question: OrchestrationV2TurnItem = { + ...base("answered-question", "2026-06-20T00:00:02.500Z", 2), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { + requestId, + questionTextById: { color: "Which color?" }, + answers: { color: attachmentsOnly ? "" : "Blue" }, + attachmentsByQuestionId: attachmentsOnly + ? { + color: [ + { + type: "file", + id: "color-file", + name: "color.txt", + mimeType: "text/plain", + sizeBytes: 4, + }, + ], + } + : {}, + }, + }; + const feed = buildThreadFeed([ + projected(userMessage(), 0), + projected(command(), 1), + projected(question, 2), + projected(assistantMessage(), 3), + ]); + const presented = deriveThreadFeedPresentation(feed, null, new Set()); + expect(presented.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "activity-group", + "message", + ]); + expect(presented[2]).toMatchObject({ + activities: [{ workEntry: { questionAnswer: question.questionAnswer } }], + }); + expect( + deriveThreadFeedPresentation(feed, null, new Set([runId])).map((entry) => entry.type), + ).toEqual(["message", "run-fold", "activity-group", "activity-group", "message"]); + }, + ); + + it.each([false, true])( + "carries a late checkpoint to only the final answer without changing cached messages (cache: %s)", + (cached) => { + const cache = cached ? createThreadFeedCache() : undefined; + const earlier = { + ...assistantMessage(), + id: TurnItemId.make("earlier-item"), + messageId: MessageId.make("earlier-message"), + text: "Working on the files", + }; + const final = assistantMessage("2026-06-20T00:00:04.000Z"); + const rows = [projected(earlier, 0), projected(final, 1)]; + const before = buildThreadFeed(rows, { cache }); + const [summary] = deriveThreadCheckpointSummaries({ + messages: [earlier, final].map((message) => ({ + ...message, + id: message.messageId, + role: "assistant", + createdBy: "agent", + creationSource: "provider", + attachments: [], + createdAt: message.startedAt, + })), + checkpoints: [ + { + id: CheckpointId.make("checkpoint-final"), + threadId, + scopeId: CheckpointScopeId.make("scope"), + runId, + nodeId: NodeId.make("node"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 2, + ref: CheckpointRef.make("checkpoint-ref"), + status: "ready", + capturedAt: final.updatedAt, + files: [ + { path: "src/one.ts", kind: "modified", additions: 10, deletions: 2 }, + { path: "src/two.ts", kind: "added", additions: 5, deletions: 0 }, + ], + }, + ], + }); + expect(summary).toBeDefined(); + if (!summary) return; + const after = buildThreadFeed(rows, { checkpointSummaries: [summary], cache }); + expect(after[0]).toBe(before[0]); + expect(after[1]).toMatchObject({ + type: "message", + message: { id: final.messageId, checkpointSummary: summary }, + }); + expect(before[1]).not.toHaveProperty("message.checkpointSummary"); + expect(getReviewSectionIdForCheckpoint(summary)).toBe("turn:2"); + const fallback = deriveThreadFeedPresentation( + [after.at(-1)!], + null, + new Set(), + new Set(), + null, + false, + cache, + ); + expect(fallback.at(-1)).toMatchObject({ message: { checkpointSummary: summary } }); + const workRows = [ + projected(userMessage(), 0), + projected(command(), 1), + projected({ ...command(), id: TurnItemId.make("second-command") }, 2), + projected(final, 3), + ]; + const workBefore = buildThreadFeed(workRows, { cache }); + deriveThreadFeedPresentation(workBefore, null, new Set(), new Set(), null, false, cache); + const workAfter = buildThreadFeed(workRows, { checkpointSummaries: [summary], cache }); + for (const expanded of [new Set(), new Set([runId])]) { + const presented = deriveThreadFeedPresentation( + workAfter, + null, + expanded, + new Set(), + null, + false, + cache, + ); + expect(presented.find((entry) => entry.type === "run-fold")).toMatchObject({ + checkpointSummary: summary, + }); + expect(presented.at(-1)).not.toHaveProperty("message.checkpointSummary"); + } + const active = deriveThreadFeedPresentation( + workAfter, + { + runId, + status: "running", + startedAt: "2026-06-20T00:00:00.000Z", + completedAt: null, + }, + new Set(), + new Set(), + null, + false, + cache, + ); + expect(active.find((entry) => entry.type === "work-toggle")).toMatchObject({ + checkpointSummary: summary, + }); + expect(active.at(-1)).not.toHaveProperty("message.checkpointSummary"); + expect(workBefore.at(-1)).not.toHaveProperty("message.checkpointSummary"); + + for (const checkpoint of [ + { ...summary, files: [] }, + ...(["missing", "error", "stale"] as const).map((status) => ({ ...summary, status })), + { ...summary, runId: RunId.make("another-run") }, + { ...summary, assistantMessageId: MessageId.make("unloaded-message") }, + ]) { + expect(buildThreadFeed(rows, { checkpointSummaries: [checkpoint], cache })).toEqual(before); + } + expect(buildThreadFeed(rows, { cache })).toEqual(before); + }, + ); it("omits cached tool output and patch bodies from expanded and copied activity", () => { const rawOutput = "RAW_TOOL_OUTPUT"; @@ -365,73 +371,6 @@ describe("buildThreadFeed", () => { expect(items[0]).toMatchObject({ output: rawOutput }); }); - it("expands tool rows only when they have detail or withheld output", () => { - const items: OrchestrationV2TurnItem[] = [ - { ...command(), input: "", outputOmitted: true }, - { - ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2), - type: "dynamic_tool", - toolName: "example", - input: {}, - }, - { - ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3), - type: "dynamic_tool", - toolName: "Read", - input: { path: "src/env.ts" }, - outputOmitted: true, - }, - ]; - const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap( - (entry) => (entry.type === "activity-group" ? entry.activities : []), - ); - expect( - activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })), - ).toEqual([ - { canExpand: true, fetchesDetail: true }, - { canExpand: false, fetchesDetail: false }, - { canExpand: true, fetchesDetail: true }, - ]); - }); - - it("recognizes automation attribution after projecting a user message", () => { - const feed = buildThreadFeed([ - projected( - { - ...userMessage(), - createdBy: "agent", - creationSource: "server", - scheduledTaskId: ScheduledTaskId.make("daily-audit"), - }, - 0, - ), - ]); - const messageEntry = feed.find((entry) => entry.type === "message"); - - expect(messageEntry).toBeDefined(); - expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ - text: "Run checks", - attribution: "automation", - }); - }); - - it("keeps the sender of an agent message distinct from its timeline source", () => { - const feed = buildThreadFeed([ - projected( - { - ...userMessage(), - createdBy: "agent", - creationSource: "mcp", - senderThreadId: sourceThreadId, - }, - 0, - ), - ]); - const messageEntry = feed.find((entry) => entry.type === "message"); - expect(messageEntry?.message.senderThreadId).toBe(sourceThreadId); - expect(messageEntry?.message.sourceThreadId).toBe(threadId); - }); - it("anchors feedback before later committed turns", () => { const laterUser = { ...userMessage("2026-08-29T00:00:05.000Z"), @@ -486,304 +425,23 @@ describe("buildThreadFeed", () => { ).toBe(true); }); - it("keeps prominent activity visible while it is running", () => { - expect( - threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }), - ).toBe(true); - expect( - threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }), - ).toBe(false); - }); - - it("keeps provider notices visible outside completed work folds without failure styling", () => { - const message = "Safeguards flagged this message. Switched to Opus 4.8."; - const item = { - ...base("item-system-notice", "2026-06-20T00:00:02.000Z", 1), - type: "system_notice" as const, - message, - }; - const feed = buildThreadFeed([projected(item, 0)]); - const presented = deriveThreadFeedPresentation(feed, null, new Set()); - const activities = presented.flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - expect(activities).toHaveLength(1); - expect(activities[0]).toMatchObject({ - summary: message, - detail: message, - prominent: true, - toolLike: false, - status: null, - icon: "warning", - workEntry: { tone: "info", itemType: "system_notice" }, - }); - expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); - }); + it("preserves authoritative V2 order instead of sorting reconstructed collections", () => { + const rows = [ + projected(userMessage("2026-06-20T00:00:03.000Z"), 0), + projected(command("2026-06-20T00:00:01.000Z"), 1), + projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2), + ]; - it("presents a usage-limit stop as a warning while preserving its explanation", () => { - const message = "Plan usage limit reached. Try again after reset."; - const entries = buildThreadFeed([ - projected( - { - ...base("item-limit", "2026-06-20T00:00:02.000Z", 1), - type: "error", - status: "failed", - title: "Usage limit reached", - failure: { class: "usage_limit", message, code: "usageLimitExceeded", retryable: null }, - }, - 0, - ), + const feed = buildThreadFeed(rows); + expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]); + expect(feed.map((entry) => entry.id)).toEqual([ + "message-user", + "local:thread-1:item-command", + "message-assistant", ]); - const activity = entries.flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - )[0]; - expect(activity).toMatchObject({ - summary: "Usage limit reached", - status: "neutral", - icon: "warning", - }); - expect(activity?.getFullDetail()).toContain(message); - }); - - it.each(["transport_error", "usage_limit"] as const)( - "presents %s retries and clears warning markers on recovery", - (failureClass) => { - const retryBase = { - ...base("item-provider-retry", "2026-06-20T00:00:02.000Z", 1), - type: "error" as const, - failure: { - class: failureClass, - message: "The response stream disconnected.", - code: "responseStreamDisconnected", - retryable: true, - }, - retry: { - attempt: 2, - maxAttempts: 5, - retryDelayMs: null, - }, - }; - const runningFeed = buildThreadFeed([ - projected( - { - ...retryBase, - status: "running", - title: "Provider retry", - completedAt: null, - }, - 0, - ), - ]); - const recoveredFeed = buildThreadFeed([ - projected( - { - ...retryBase, - status: "completed", - title: "Provider recovered", - }, - 0, - ), - ]); - if (failureClass === "usage_limit") { - const recoveredActivity = recoveredFeed.flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - )[0]; - expect(recoveredActivity).toMatchObject({ status: "success", icon: "check" }); - } - const failedFeed = buildThreadFeed([ - projected( - { - ...retryBase, - status: "failed", - title: "Provider retry failed", - }, - 0, - ), - projected(command("2026-06-20T00:00:03.000Z"), 1), - ]); - const runningActivity = runningFeed.find((entry) => entry.type === "activity-group") - ?.activities[0]; - const recoveredActivity = recoveredFeed.find((entry) => entry.type === "activity-group") - ?.activities[0]; - if (runningActivity === undefined || recoveredActivity === undefined) { - throw new Error("Expected provider retry work-log activities."); - } - - expect(runningActivity).toMatchObject({ - summary: "Provider retry", - status: "neutral", - toolLike: false, - }); - expect(threadFeedActivityIsVisible(runningActivity)).toBe(true); - expect(recoveredActivity).toMatchObject({ - summary: "Provider recovered", - status: "success", - toolLike: false, - }); - const failedPresentation = deriveThreadFeedPresentation( - failedFeed, - { runId, status: "running", startedAt: null, completedAt: null }, - new Set(), - ); - expect(failedPresentation.map((entry) => entry.type)).toEqual([ - "activity-group", - "activity-group", - ]); - expect( - failedPresentation[0]?.type === "activity-group" - ? failedPresentation[0].activities[0]?.summary - : null, - ).toBe("Provider retry failed"); - }, - ); - - it.each(["pending", "running", "completed"] as const)( - "omits %s task progress without hiding adjacent conversation items", - (stepStatus) => { - const todoItem = { - ...base("item-tasks", "2026-06-20T00:00:02.500Z", 2), - type: "todo_list" as const, - planId: PlanId.make("plan-tasks"), - steps: [{ id: "step-1", text: "Verify the change", status: stepStatus }], - } satisfies OrchestrationV2TurnItem; - const user = projected(userMessage(), 0); - const tool = projected(command(), 1); - const assistant = projected(assistantMessage(), 3); - - expect(buildThreadFeed([user, tool, projected(todoItem, 2), assistant])).toEqual( - buildThreadFeed([user, tool, assistant]), - ); - }, - ); - - it("hides synthetic workspace preparation activity", () => { - const workspacePreparation = projected( - { - ...command(), - title: "Workspace ready", - input: "Preparing workspace", - output: "Workspace preparation completed.", - }, - 0, - ); - - expect(buildThreadFeed([workspacePreparation])).toEqual([]); - }); - - it("does not treat a queued-only run as live feed activity", () => { - expect( - threadFeedRunIsUnsettled({ - runId, - status: "queued", - startedAt: null, - completedAt: null, - }), - ).toBe(false); - expect( - threadFeedRunIsUnsettled({ - runId, - status: "running", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }), - ).toBe(true); - expect( - threadFeedRunIsUnsettled({ - runId, - status: "completed", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }), - ).toBe(true); - expect( - threadFeedRunIsUnsettled({ - runId, - status: "waiting", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }), - ).toBe(true); - }); - - it("adds queued input only after dispatch creates its turn item", () => { - const dispatchedRunId = RunId.make("run-dispatched-queued"); - const dispatchedMessageId = MessageId.make("message-dispatched-queued"); - expect(buildThreadFeed([])).toEqual([]); - - const promotedEntries = buildThreadFeed([ - projected( - { - ...userMessage(), - id: TurnItemId.make("item-dispatched-queued"), - runId: dispatchedRunId, - messageId: dispatchedMessageId, - inputIntent: "turn_start", - }, - 0, - ), - ]); - expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]); - expect( - promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined, - ).toBe("turn_start"); - }); - - it("hides the interruption request and keeps the terminal result", () => { - const request = projected( - { - ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1), - type: "run_interrupt_request", - message: "Interrupt requested", - }, - 0, - ); - const result = projected( - { - ...base("item-interrupt-result", "2026-06-20T00:00:03.000Z", 2), - type: "run_interrupt_result", - message: "Run interrupted before provider start", - }, - 1, - ); - - const activities = buildThreadFeed([request, result]).flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - - expect(activities).toHaveLength(1); - expect(activities[0]?.summary).toBe("Run interrupted"); - expect(activities[0]?.detail).toBe("Run interrupted before provider start"); - expect( - deriveThreadFeedPresentation( - buildThreadFeed([request, result]), - { - runId, - status: "interrupted", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:03.000Z", - }, - new Set(), - ).some((entry) => entry.type === "run-fold"), - ).toBe(false); - }); - - it("preserves authoritative V2 order instead of sorting reconstructed collections", () => { - const rows = [ - projected(userMessage("2026-06-20T00:00:03.000Z"), 0), - projected(command("2026-06-20T00:00:01.000Z"), 1), - projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2), - ]; - - const feed = buildThreadFeed(rows); - expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]); - expect(feed.map((entry) => entry.id)).toEqual([ - "message-user", - "local:thread-1:item-command", - "message-assistant", - ]); - const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0]; - expect(activity?.projectedItem).toBe(rows[1]); - expect(activity?.getFullDetail()).toContain('"input": "vp check"'); + const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0]; + expect(activity?.projectedItem).toBe(rows[1]); + expect(activity?.getFullDetail()).toContain('"input": "vp check"'); }); it("keeps adjacent work from different V2 attempts in separate groups", () => { @@ -867,57 +525,7 @@ describe("buildThreadFeed", () => { expect(activities.at(-1)?.prominent).toBe(true); }); - it("keeps orchestration relationship cards visible when a completed run is folded", () => { - const { providerThreadId: _providerThreadId, ...forkBase } = base( - "item-fork", - "2026-06-20T00:00:02.500Z", - 2, - ); - const feed = buildThreadFeed([ - projected(userMessage(), 0), - projected(command(), 1), - projected( - { - ...forkBase, - type: "fork", - source: { type: "run", threadId, runId }, - targetThreadId: sourceThreadId, - }, - 2, - ), - projected(assistantMessage(), 3), - ]); - - const collapsed = deriveThreadFeedPresentation( - feed, - { - runId, - status: "completed", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:03.000Z", - }, - new Set(), - ); - - expect( - collapsed.some( - (entry) => - entry.type === "activity-group" && - entry.activities.some((activity) => activity.projectedItem.item.type === "fork"), - ), - ).toBe(true); - expect( - collapsed.some( - (entry) => - entry.type === "activity-group" && - entry.activities.some( - (activity) => activity.projectedItem.item.type === "command_execution", - ), - ), - ).toBe(false); - }); - - it("keeps opening and final assistant messages around the first hidden work", () => { + it("folds progress messages while keeping the final assistant answer visible", () => { const opening = { ...assistantMessage("2026-06-20T00:00:01.500Z"), id: TurnItemId.make("item-opening"), @@ -947,30 +555,41 @@ describe("buildThreadFeed", () => { const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); expect(collapsed.map((entry) => entry.id)).toEqual([ "message-user", - "message-opening", "run-fold:run-1", "message-assistant", ]); - expect(collapsed[1]).toMatchObject({ message: { text: opening.text } }); - expect(collapsed[2]).toMatchObject({ + expect(collapsed[1]).toMatchObject({ type: "run-fold", - createdAt: "2026-06-20T00:00:02.000Z", + createdAt: "2026-06-20T00:00:01.500Z", label: "Worked for 2.0s", }); const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); expect(expanded.map((entry) => entry.type)).toEqual([ - "message", "message", "run-fold", + "message", "activity-group", "message", "message", ]); expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); + + const active = deriveThreadFeedPresentation( + feed, + { ...latestRun, status: "running", completedAt: null }, + new Set(), + ); + expect(active.filter((entry) => entry.type === "message").map((entry) => entry.id)).toEqual([ + "message-user", + "message-opening", + "message-middle", + "message-assistant", + ]); }); - it("does not fold a response that only has opening and final messages", () => { + // regression: #21393 show plans and fold settled progress notes + it("folds progress even when a response has no tool calls", () => { const feed = buildThreadFeed([ projected(userMessage(), 0), projected( @@ -988,7 +607,7 @@ describe("buildThreadFeed", () => { const presented = deriveThreadFeedPresentation(feed, null, new Set()); expect(presented.map((entry) => entry.id)).toEqual([ "message-user", - "message-opening", + "run-fold:run-1", "message-assistant", ]); }); @@ -1136,54 +755,6 @@ describe("buildThreadFeed", () => { expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); }); - it("folds settled V2 run work while keeping the terminal assistant message visible", () => { - const feed = buildThreadFeed([ - projected(userMessage(), 0), - projected(command(), 1), - projected(assistantMessage(), 2), - ]); - const latestRun = { - runId, - status: "completed" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:03.000Z", - }; - - const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); - expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); - - const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); - expect(expanded.map((entry) => entry.type)).toEqual([ - "message", - "run-fold", - "activity-group", - "message", - ]); - }); - - it("keeps an active run expanded and detects failures from completed command output", () => { - const failedCommand: OrchestrationV2TurnItem = { - ...command(), - output: "sh: missing-command: command not found", - }; - const feed = buildThreadFeed([projected(userMessage(), 0), projected(failedCommand, 1)]); - const presented = deriveThreadFeedPresentation( - feed, - { - runId, - status: "running", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }, - new Set(), - ); - - expect(presented.some((entry) => entry.type === "run-fold")).toBe(false); - expect(presented.find((entry) => entry.type === "activity-group")).toMatchObject({ - activities: [{ status: "failure", live: false, groupStandIn: { label: "vp check" } }], - }); - }); - it("folds each run of a provider-native subagent thread like a normal turn", () => { const runless = (item: T, id: string, ordinal: number) => ({ ...item, @@ -1303,7 +874,7 @@ describe("buildThreadFeed", () => { new Set(), "2026-06-20T00:01:00.000Z", ) - .slice(0, 4) + .slice(0, 3) .map((entry) => (entry.type === "message" ? entry.message.role : entry.type)); expect( @@ -1312,7 +883,7 @@ describe("buildThreadFeed", () => { id: TurnItemId.make("new-prompt"), messageId: MessageId.make("new-prompt"), }), - ).toEqual(["user", "assistant", "run-fold", "assistant"]); + ).toEqual(["user", "run-fold", "assistant"]); expect( presented({ ...base("wake", "2026-06-20T00:01:00.000Z", 4), @@ -1321,7 +892,7 @@ describe("buildThreadFeed", () => { outcome: "completed", summary: "Background task finished", }), - ).toEqual(["user", "assistant", "run-fold", "assistant"]); + ).toEqual(["user", "run-fold", "assistant"]); }); it("keeps a provider-native subagent's runless tool call live while it works", () => { @@ -1354,42 +925,6 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); - it("keeps a runless tail folded while a normal thread waits for its sent run", () => { - const startedAt = "2026-06-20T00:00:05.000Z"; - const feed = buildThreadFeed([ - projected({ ...userMessage(), runId: null }, 0), - projected({ ...command(), runId: null }, 1), - ]); - for (const latestRun of [ - null, - { runId, status: "queued" as const, startedAt: null, completedAt: null }, - ]) { - const presented = deriveThreadFeedPresentation( - feed, - latestRun, - new Set(), - new Set(), - startedAt, - ); - expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]); - } - }); - - it("waits for workspace preparation before showing provider activity", () => { - const startedAt = "2026-04-01T00:00:01.000Z"; - const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; - expect(deriveThreadFeedPresentation([], run, new Set(), new Set(), startedAt)).toEqual([]); - expect( - deriveThreadFeedPresentation( - [], - { ...run, status: "running", startedAt }, - new Set(), - new Set(), - startedAt, - ), - ).toEqual([{ type: "thinking", id: "live-activity-row", createdAt: startedAt, runId }]); - }); - it("does not show Thinking once the open run's provider turn has ended", () => { const startedAt = "2026-04-01T00:00:01.000Z"; const run = { runId, status: "running" as const, startedAt, completedAt: null }; @@ -1403,374 +938,6 @@ describe("buildThreadFeed", () => { ), ).toEqual([]); }); - - it("uses a stable Thinking row while work has started without a projected item", () => { - const startedAt = "2026-04-01T00:00:01.000Z"; - const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); - - expect(presented).toEqual([ - { type: "thinking", id: "live-activity-row", createdAt: startedAt, runId: null }, - ]); - expect(deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt)[0]).toBe( - presented[0], - ); - }); - - it("keeps expanded work in one group with stable row identities", () => { - const activity = ( - id: string, - createdAt: string, - status: ThreadFeedActivity["status"] = "success", - ): ThreadFeedActivity => ({ - id, - createdAt, - runId: null, - attemptId: null, - summary: `Tool ${id}`, - detail: null, - canExpand: false, - fetchesDetail: false, - getFullDetail: () => null, - getCopyText: () => id, - icon: "command", - logo: null, - toolLike: true, - prominent: false, - status, - lifecycleStatus: status === "neutral" ? "inProgress" : "completed", - workEntry: { - id, - createdAt, - label: `Tool ${id}`, - tone: "tool", - command: "vp check", - itemType: "command_execution", - toolLifecycleStatus: status === "neutral" ? "inProgress" : "completed", - }, - projectedItem: projected(command(createdAt), 0), - }); - const feed: ThreadFeedEntry[] = [ - { - type: "activity-group", - id: "work-group-1", - createdAt: "2026-04-01T00:00:01.000Z", - runId: null, - activities: [ - activity("activity-neutral", "2026-04-01T00:00:01.000Z", "neutral"), - activity("activity-1", "2026-04-01T00:00:02.000Z"), - activity("activity-2", "2026-04-01T00:00:03.000Z"), - activity("activity-3", "2026-04-01T00:00:04.000Z"), - ], - }, - ]; - - const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-neutral"]); - expect(collapsed[0]).toMatchObject({ - type: "work-toggle", - groupId: "work-group:activity-neutral", - hiddenCount: 3, - expanded: false, - summary: "Ran 3 commands", - }); - - const expanded = deriveThreadFeedPresentation( - feed, - null, - new Set(), - new Set(["work-group:activity-neutral"]), - ); - expect(expanded.map((entry) => entry.id)).toEqual([ - "work-toggle:work-group:activity-neutral", - "work-details:work-group:activity-neutral", - ]); - expect(expanded[0]).toMatchObject({ - type: "work-toggle", - expanded: true, - }); - expect(expanded[1]).toMatchObject({ - type: "activity-group", - activities: [ - { id: "activity-1", groupedToolDetail: true, live: false }, - { id: "activity-2", groupedToolDetail: true, live: false }, - { id: "activity-3", groupedToolDetail: true, live: false }, - ], - }); - }); - - describe("a lone call", () => { - const wait = (overrides: Partial = {}) => - ({ - ...base("wait", "2026-06-20T00:00:02.000Z", 1), - type: "dynamic_tool", - toolName: "t3-code.t3_thread_wait", - input: { threadId: "thread-child", timeoutMs: 50_000 }, - output: { threadId: "thread-child", status: "timeout" }, - ...overrides, - }) as OrchestrationV2TurnItem; - const read = (overrides: Partial = {}) => - ({ - ...base("read", "2026-06-20T00:00:03.000Z", 2), - type: "dynamic_tool", - toolName: "t3-code.t3_thread_read", - input: { threadId: "thread-child" }, - output: { messages: [] }, - ...overrides, - }) as OrchestrationV2TurnItem; - const running = { status: "running", completedAt: null, output: null } as const; - const startedAt = "2026-06-20T00:00:01.000Z"; - const liveRows = (items: ReadonlyArray, groupIds: string[] = []) => - deriveThreadFeedPresentation( - buildThreadFeed(items.map((item, position) => projected(item, position))), - { runId, status: "running", startedAt, completedAt: null }, - new Set(), - new Set(groupIds), - startedAt, - ); - const standIn = (rows: ReadonlyArray) => { - const row = rows.find((entry) => entry.type === "activity-group"); - if (row?.type !== "activity-group") throw new Error("Expected the call's own row"); - return row.activities[0]?.groupStandIn; - }; - - it("is its own row, with no group header to open first", () => { - // The second call never finished, so only one call is visible. - for (const items of [[wait()], [wait(), read(running)]]) { - const feed = buildThreadFeed( - [userMessage(), ...items, assistantMessage("2026-06-20T00:00:04.000Z")].map( - (item, position) => projected(item, position), - ), - ); - const rows = deriveThreadFeedPresentation(feed, null, new Set([runId])); - expect(rows.map((entry) => entry.type)).toEqual([ - "message", - "run-fold", - "activity-group", - "message", - ]); - expect(rows[2]).toMatchObject({ - activities: [{ canExpand: true, groupStandIn: { label: "Waited for a T3 thread" } }], - }); - expect(rows[2]).not.toMatchObject({ activities: [{ groupedToolDetail: true }] }); - // The group's own disclosure adds nothing while the call is alone. - expect( - deriveThreadFeedPresentation( - feed, - null, - new Set([runId]), - new Set([standIn(rows)!.groupId]), - ).map((entry) => entry.id), - ).toEqual(rows.map((entry) => entry.id)); - } - }); - - it("offers no disclosure when it has nothing to show", () => { - const rows = deriveThreadFeedPresentation( - buildThreadFeed([projected(wait({ toolName: "example", input: {}, output: null }), 0)]), - null, - new Set([runId]), - ); - expect(rows.map((entry) => entry.type)).toEqual(["run-fold", "activity-group"]); - expect(rows[1]).toMatchObject({ activities: [{ canExpand: false }] }); - }); - - it("holds the live slot until a second call makes it a group", () => { - for (const item of [wait(running), wait()]) { - const rows = liveRows([item]); - expect(rows.map((entry) => entry.type)).toEqual(["activity-group"]); - expect(rows[0]).toMatchObject({ - id: "live-activity-row", - activities: [{ live: true, groupStandIn: { label: "Waiting for a T3 thread" } }], - }); - } - - const groupId = standIn(liveRows([wait()]))!.groupId; - const grouped = liveRows([wait(), read(running)], [groupId]); - expect(grouped.map((entry) => entry.type)).toEqual(["work-toggle", "activity-group"]); - expect(grouped[0]).toMatchObject({ - id: "live-activity-row", - groupId, - hiddenCount: 2, - expanded: true, - summary: "Reading a T3 thread", - }); - expect(grouped[1]).toMatchObject({ - activities: [ - { groupedToolDetail: true, live: false }, - { groupedToolDetail: true, live: true }, - ], - }); - expect(standIn(grouped)).toBeUndefined(); - }); - }); - - it("retains Claude Read image previews without tool output", () => { - const item = { - ...base("image-read", "2026-06-20T00:00:04.000Z", 3), - type: "dynamic_tool" as const, - toolName: "Read", - input: { file_path: "/workspace/reference.png" }, - viewedImagePath: "/workspace/reference.png", - } satisfies OrchestrationV2TurnItem; - const feed = buildThreadFeed([projected(item, 0)]); - const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - expect(activity?.workEntry.viewedImagePath).toBe("/workspace/reference.png"); - }); - - it("pretty prints T3 MCP dynamic tool activities and attaches the product logo", () => { - const toolItem: OrchestrationV2TurnItem = { - ...base("item-t3-tool", "2026-06-20T00:00:04.000Z", 3), - type: "dynamic_tool", - toolName: "mcp__t3-code__t3_thread_read", - input: { threadId: "thread-child" }, - output: { messages: [] }, - }; - - const feed = buildThreadFeed([projected(toolItem, 0)]); - const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - - expect(activity?.summary).toBe("Read a T3 thread"); - expect(activity?.logo).toBe("t3-code"); - expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread"); - }); - - it.each(["cua_repl.js", "node_repl.js", "js"])( - "uses the %s action title in the mobile feed", - (toolName) => { - const item: OrchestrationV2TurnItem = { - ...base("cua", "2026-09-23T20:20:00.000Z", 1), - type: "dynamic_tool", - toolName, - input: { - code: "await game.getAXStateAndScreenshot();", - title: "Inspect Saga music screen", - }, - }; - const feed = buildThreadFeed([projected(item, 0)]); - const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - expect(activity?.summary).toBe("Inspect Saga music screen"); - }, - ); - - it.each([ - [ - "failed", - "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", - "Restrict the breakpoint to Git actions", - "computer", - ], - [ - "completed", - "await fetch('http://127.0.0.1/json/list');", - "Discover the live Electron debugging endpoint", - "code", - ], - ] as const)("recovers the retained js title and %s call icon", (status, code, title, icon) => { - const item: OrchestrationV2TurnItem = { - ...base("retained-js", "2026-09-23T20:20:00.000Z", 1), - type: "dynamic_tool", - status, - title: "js", - toolName: "node_repl.js", - toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" }, - input: { code, title }, - }; - const feed = buildThreadFeed([projected(item, 0)]); - const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null; - expect(activity?.summary).toBe(title); - expect(activity?.icon).toBe(icon); - expect(activity?.workEntry.toolSource?.kind).toBe( - icon === "computer" ? "computer" : "integration", - ); - }); - - it("uses canonical T3 orchestration summaries in compact work groups", () => { - const rows = [ - projected(command("2026-06-20T00:00:01.000Z"), 0), - ...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map( - (toolName, index) => - projected( - { - ...base(`item-send-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 2), - type: "dynamic_tool" as const, - toolName, - input: { threadId: `thread-${index}`, message: "Continue" }, - output: { threadId: `thread-${index}`, messageId: `message-${index}` }, - }, - index + 1, - ), - ), - projected( - { - ...command("2026-06-20T00:00:06.000Z"), - id: TurnItemId.make("item-command-2"), - ordinal: 6, - }, - 4, - ), - ]; - - const presented = deriveThreadFeedPresentation( - buildThreadFeed(rows), - { runId, status: "running", startedAt: null, completedAt: null }, - new Set(), - ); - - expect(presented).toMatchObject([ - { - type: "work-toggle", - summary: "Ran 2 commands and sent messages to 3 threads", - hiddenCount: 5, - hasFailure: false, - }, - ]); - }); - - it("presents project calls and summarizes successful clones through the mobile feed", () => { - const items: OrchestrationV2TurnItem[] = [ - { - ...base("list", "2026-09-19T00:00:01.000Z", 1), - type: "dynamic_tool", - title: "Custom provider title", - toolName: "T3-code.t3_project_list", - input: {}, - output: { projects: [] }, - }, - { - ...base("clone", "2026-09-19T00:00:02.000Z", 2), - type: "dynamic_tool", - title: "Custom provider title", - toolName: "mcp__t3_code__t3_project_clone", - input: {}, - output: { cwd: "/tmp/repo" }, - }, - { - ...base("failed-clone", "2026-09-19T00:00:03.000Z", 3), - type: "dynamic_tool", - title: "Custom provider title", - toolName: "t3_project_clone", - input: {}, - output: { isError: true }, - }, - ]; - const feed = buildThreadFeed(items.map((item, position) => projected(item, position))); - const activities = feed.flatMap((entry) => - entry.type === "activity-group" ? entry.activities : [], - ); - expect(workEntryRowLabel(activities[0]!.workEntry)).toBe("Listed projects"); - expect(workEntryRowLabel(activities[1]!.workEntry)).toBe("Cloned a repository"); - expect(workEntryRowLabel(activities[2]!.workEntry)).toBe("Failed to clone a repository"); - expect(activities.every((activity) => activity.logo === "t3-code")).toBe(true); - const presented = deriveThreadFeedPresentation( - feed, - { runId, status: "running", startedAt: null, completedAt: null }, - new Set(), - ); - expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ - summary: "Listed projects 1 time and cloned 1 repository", - hasFailure: true, - }); - }); }); describe("retained v2 feed presentation", () => { @@ -1816,229 +983,6 @@ describe("retained v2 feed presentation", () => { expect(afterPresentation[1]).toBe(beforePresentation[1]); }); - it.each([ - { status: "running", summary: "Compacting context" }, - { status: "completed", summary: "Context compacted 899K → 19K tokens" }, - { status: "failed", summary: "Context compaction failed" }, - { status: "interrupted", summary: "Context compaction stopped" }, - ] as const)( - "uses the compaction row as the live activity only while $status", - ({ status, summary }) => { - const compact = projected( - { - ...base("compacted", "2026-06-20T00:00:02.000Z", 1), - type: "compaction", - status, - driver: null, - beforeTokenCount: 899_000, - ...(status === "completed" ? { afterTokenCount: 19_000 } : {}), - }, - 1, - ); - const latestRun = { - runId, - status: "running" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: null, - }; - const rows = deriveThreadFeedPresentation( - buildThreadFeed([projected(userMessage(), 0), compact]), - latestRun, - new Set(), - new Set(), - latestRun.startedAt, - ); - expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running"); - expect(rows.find((row) => row.type === "activity-group")).toMatchObject({ - activities: [{ summary }], - }); - }, - ); - - it.each(["running", "completed", "failed"] as const)( - "keeps a %s handoff separate from commands and visible through folds", - (status) => { - const handoff = projected( - { - ...base("handoff", "2026-06-20T00:00:02.000Z", 1), - type: "handoff", - status, - contextHandoffId: ContextHandoffId.make("handoff"), - fromProviderThreadIds: [], - toProviderThreadId: ProviderThreadId.make("target"), - fromProviderInstanceIds: [ProviderInstanceId.make("codex")], - toProviderInstanceId: ProviderInstanceId.make("claudeAgent"), - strategy: "full_thread_summary", - summary: "Private full conversation summary", - }, - 1, - ); - const feed = buildThreadFeed([ - projected(userMessage(), 0), - handoff, - projected(command("2026-06-20T00:00:03.000Z"), 2), - projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3), - ]); - for (const expanded of [new Set(), new Set([runId])]) { - const rows = deriveThreadFeedPresentation(feed, null, expanded); - const divider = rows.filter( - (entry) => entry.type === "activity-group" && isContextHandoffActivityGroup(entry), - ); - expect(divider).toHaveLength(1); - expect(divider[0]).toMatchObject({ activities: [{ projectedItem: handoff }] }); - } - const alone = deriveThreadFeedPresentation( - buildThreadFeed([projected(userMessage(), 0), handoff]), - null, - new Set(), - ); - expect(alone.map((entry) => entry.type)).toEqual(["message", "activity-group"]); - }, - ); - - it("keeps a standalone compaction visible and folds it with other completed work", () => { - const compact = projected( - { - ...base("compacted", "2026-06-20T00:00:02.000Z", 1), - type: "compaction", - driver: null, - summary: "Shorter context", - }, - 1, - ); - const latestRun = { - runId, - status: "completed" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:04.000Z", - }; - const onlyCompaction = deriveThreadFeedPresentation( - buildThreadFeed([projected(userMessage(), 0), compact]), - latestRun, - new Set(), - ); - expect(onlyCompaction.map((entry) => entry.type)).toEqual(["message", "activity-group"]); - const feed = buildThreadFeed([ - projected(userMessage(), 0), - compact, - projected(command("2026-06-20T00:00:03.000Z"), 2), - projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3), - ]); - expect( - deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type), - ).toEqual(["message", "run-fold", "message"]); - const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); - expect( - expanded.find( - (entry) => - entry.type === "activity-group" && - entry.activities[0]?.projectedItem.item.type === "compaction", - ), - ).toMatchObject({ activities: [{ summary: "Context compacted" }] }); - }); - - it("retains assistant image attachments from the wire", () => { - const image = { - type: "image" as const, - id: "assistant-image", - name: "result.png", - mimeType: "image/png", - sizeBytes: 100, - }; - const feed = buildThreadFeed([ - projected({ ...assistantMessage(), text: "", attachments: [image] }, 0), - ]); - expect(feed).toMatchObject([ - { type: "message", message: { role: "assistant", attachments: [image] } }, - ]); - }); - - it("keeps native application icons and source identity in collapsed and expanded work", () => { - const icon = { - _tag: "native-app" as const, - app: { _tag: "app-id" as const, appId: "com.example.Editor" }, - }; - const source = { - key: "native-app:com.example.editor", - name: "Editor", - kind: "computer" as const, - icon, - }; - const rows = [0, 1].map((index) => - projected( - { - ...base(`native-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 1), - type: "dynamic_tool" as const, - toolName: "computer.click", - input: { x: index, y: 1 }, - output: null, - toolSurface: "computer" as const, - toolIcon: icon, - toolSource: source, - }, - index, - ), - ); - const feed = buildThreadFeed(rows); - const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null }; - const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set()); - const toggle = collapsed[0]; - if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group"); - const presented = deriveThreadFeedPresentation( - feed, - latestRun, - new Set(), - new Set([toggle.groupId]), - ); - expect(presented[0]).toMatchObject({ - type: "work-toggle", - summary: "Used Editor", - toolSurface: "computer", - toolIcon: icon, - }); - expect(presented[1]).toMatchObject({ - type: "activity-group", - activities: [ - { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } }, - { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } }, - ], - }); - }); - - it.each([ - ["failed", "Failed to click in the preview browser", "failure"], - ["cancelled", "Stopped clicking in the preview browser", "neutral"], - ] as const)( - "keeps %s calls terminal while the parent run remains live", - (status, label, activityStatus) => { - const feed = buildThreadFeed([ - projected( - { - ...base("preview-click", "2026-06-20T00:00:02.000Z", 1), - type: "dynamic_tool", - status, - toolName: "mcp__t3-code__preview_click", - input: { element: "button" }, - output: null, - }, - 0, - ), - ]); - const rows = deriveThreadFeedPresentation( - feed, - { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null }, - new Set(), - new Set(), - "2026-06-20T00:00:01.000Z", - ); - expect(rows[0]).toMatchObject({ - type: "activity-group", - activities: [{ status: activityStatus, live: false, groupStandIn: { label } }], - }); - expect(rows.at(-1)?.type).toBe("thinking"); - }, - ); - it.each([ { envelope: "direct", output: { taskId: "a" } }, { envelope: "structured", output: { structuredContent: { taskId: "a" } } }, @@ -2124,32 +1068,6 @@ describe("retained v2 feed presentation", () => { }, ); - it("keeps subagents from different provider turns in separate cards", () => { - const agent = (id: string, index: number) => - projected( - { - ...base(id, "2026-06-20T00:00:01.000Z", index), - type: "subagent", - subagentId: NodeId.make(id), - origin: "provider_native", - driver: ProviderDriverKind.make("codex"), - providerInstanceId: ProviderInstanceId.make("codex"), - providerTurnId: ProviderTurnId.make(id), - childThreadId: null, - prompt: "Task", - result: null, - }, - index, - ); - expect( - buildThreadFeed([agent("a", 1), agent("b", 2)]).flatMap((entry) => - entry.type === "activity-group" - ? [entry.activities.map((activity) => activity.projectedItem.item.id)] - : [], - ), - ).toEqual([["a"], ["b"]]); - }); - it("groups only adjacent subagents in the same run, keeping their child links", () => { const agent = (id: string, index: number, agentRunId = runId) => projected( @@ -2187,43 +1105,14 @@ describe("retained v2 feed presentation", () => { new Set([runId, RunId.make("other-run")]), ); const groups = presented.flatMap((entry) => - entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent" - ? [entry] - : [], - ); - expect(groups.map((entry) => entry.activities.length)).toEqual([2, 1, 1]); - expect(groups[0]?.activities.map((activity) => activity.projectedItem.item)).toMatchObject([ - { childThreadId: "child-a" }, - { childThreadId: "child-b" }, - ]); - }); - - it("shows an idle native subagent without claiming completion", () => { - const rows = buildThreadFeed([ - projected( - { - ...base("native-agent", "2026-06-20T00:00:02.000Z", 1), - type: "subagent", - status: "idle", - subagentId: NodeId.make("native-agent"), - origin: "provider_native", - driver: ProviderDriverKind.make("antigravity"), - providerInstanceId: ProviderInstanceId.make("antigravity"), - childThreadId: null, - title: "Search", - prompt: "Find relevant files", - result: null, - }, - 0, - ), - ]); - expect(rows[0]).toMatchObject({ - type: "activity-group", - activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: false }], - }); - expect(deriveThreadFeedPresentation(rows, null, new Set([runId]))).toMatchObject([ - { type: "run-fold", expanded: true }, - { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] }, + entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent" + ? [entry] + : [], + ); + expect(groups.map((entry) => entry.activities.length)).toEqual([2, 1, 1]); + expect(groups[0]?.activities.map((activity) => activity.projectedItem.item)).toMatchObject([ + { childThreadId: "child-a" }, + { childThreadId: "child-b" }, ]); }); }); @@ -2239,17 +1128,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const multiSelectQuestion = { - id: "scope", - header: "Scope", - question: "Which data should be collected?", - options: [ - { label: "Orders", description: "Receipts" }, - { label: "Listings", description: "Inventory" }, - ], - multiSelect: true, -} as const; - describe("subagent continued by a later turn", () => { const run2 = RunId.make("run-2"); const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; @@ -2320,50 +1198,9 @@ describe("subagent continued by a later turn", () => { "answer-1", "prompt-2", `run-fold:${run2}`, - "answer-2", "closing-2", ]); }); - - it("keeps a wake run's work open when the user steers it", () => { - const wakeRun = RunId.make("wake-run"); - const wakeItems: OrchestrationV2TurnItem[] = [ - user("prompt-1", runId, 1_000_001, 0), - reasoning("reasoning-1", runId, 1_000_002, 1), - assistant("answer-1", runId, 1_000_003, 3), - { - ...item("wake", wakeRun, 2_000_001, 30), - type: "notification", - source: { kind: "background_task" }, - outcome: "completed", - summary: "Background task finished", - }, - { - ...item("wake-command", wakeRun, 2_000_002, 31), - type: "command_execution", - input: "vp test", - output: "ok", - exitCode: 0, - }, - { ...user("steer", wakeRun, 2_000_003, 32), inputIntent: "steer" }, - ]; - const presented = deriveThreadFeedPresentation( - buildThreadFeed(wakeItems.map((entry, index) => projected(entry, index))), - { runId: wakeRun, status: "running", startedAt: at(30), completedAt: null }, - new Set(), - new Set(), - at(30), - ); - expect(presented.map((entry) => entry.id)).toEqual([ - "prompt-1", - `run-fold:${runId}`, - "answer-1", - "local:thread-1:wake", - "work-toggle:work-group:local:thread-1:wake-command", - "steer", - "live-activity-row", - ]); - }); }); describe("pending user input answers", () => { @@ -2389,79 +1226,6 @@ describe("pending user input answers", () => { { customAnswer: "" }, ); }); - - it("replaces single-select options and toggles multi-select options", () => { - expect( - togglePendingUserInputOptionSelection( - singleSelectQuestion, - { selectedOptionValues: ["Go"] }, - "Node.js", - ), - ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] }); - - const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); - const ordersAndListings = togglePendingUserInputOptionSelection( - multiSelectQuestion, - orders, - "Listings", - ); - expect(ordersAndListings).toEqual({ - customAnswer: "", - selectedOptionValues: ["Orders", "Listings"], - }); - expect( - togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), - ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] }); - - const paddedOrders = togglePendingUserInputOptionSelection( - multiSelectQuestion, - undefined, - " Orders ", - ); - expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] }); - expect( - togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), - ).toEqual({ customAnswer: "" }); - }); - - it("builds array answers for multi-select questions", () => { - expect( - buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { - runtime: { selectedOptionValues: ["Go"] }, - scope: { selectedOptionValues: ["Orders", "Listings"] }, - }), - ).toEqual({ - runtime: "Go", - scope: ["Orders", "Listings"], - }); - }); - - it("clears selected options while a custom answer is active", () => { - expect( - setPendingUserInputCustomAnswer( - multiSelectQuestion, - { selectedOptionValues: ["Orders", "Listings"] }, - "Orders first", - ), - ).toEqual({ customAnswer: "Orders first" }); - }); - - it("matches selected chips against normalized option labels", () => { - expect( - isPendingUserInputOptionSelected( - multiSelectQuestion, - { selectedOptionValues: ["Orders"] }, - " Orders ", - ), - ).toBe(true); - expect( - isPendingUserInputOptionSelected( - multiSelectQuestion, - { selectedOptionValues: ["Orders"], customAnswer: "Orders first" }, - " Orders ", - ), - ).toBe(false); - }); }); describe("provider question values", () => { @@ -2528,193 +1292,6 @@ it("accepts ready attachment-only answers while preserving selected options", () ).toBeNull(); }); -it("makes attachment-only question answers expandable in the mobile feed", () => { - const answer = { - requestId: RuntimeRequestId.make("question-request"), - answers: { q: "" }, - questionTextById: { q: "Attach the specification" }, - attachmentsByQuestionId: { - q: [ - { - type: "file" as const, - id: "question-file", - name: "spec.txt", - mimeType: "text/plain", - sizeBytes: 4, - }, - ], - }, - }; - const [group] = buildThreadFeed([ - projected( - { - ...base("answer-history", "2026-09-08T00:00:00.000Z", 0), - type: "user_input_request", - requestId: answer.requestId, - questions: [], - questionAnswer: answer, - }, - 0, - ), - ]); - expect(group?.type).toBe("activity-group"); - if (group?.type !== "activity-group") return; - expect(group.activities[0]).toMatchObject({ - canExpand: true, - workEntry: { questionAnswer: answer }, - }); - expect(group.activities[0]?.getFullDetail()).toContain("spec.txt"); -}); - -it("renders automatic completion as a neutral activity while retaining its details", () => { - const item = { - ...base("notification", "2026-06-20T00:00:01.000Z", 0), - type: "notification" as const, - source: { kind: "monitor" as const }, - outcome: "updated" as const, - summary: "Monitor reported an update", - detail: "Build checks changed", - }; - const feed = buildThreadFeed([ - projected(item, 0), - projected(command(), 1), - projected(assistantMessage(), 2), - ]); - expect(feed[0]?.type).toBe("activity-group"); - if (feed[0]?.type !== "activity-group") throw new Error("Expected notification activity"); - const activity = feed[0].activities[0]!; - expect(activity.summary).toBe("Monitor reported an update"); - expect(activity.detail).toBeNull(); - expect(activity.status).toBeNull(); - expect(activity.getFullDetail()).toContain(item.detail); - const presented = deriveThreadFeedPresentation( - feed, - { - runId, - status: "completed", - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:03.000Z", - }, - new Set(), - ); - expect( - presented.some( - (entry) => - entry.type === "activity-group" && - entry.activities.some((activity) => activity.summary === "Monitor reported an update"), - ), - ).toBe(true); - expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message"); -}); - -it("uses a compact reasoning preview and a short expanded heading", () => { - const entry = { - id: "thought", - label: "Thinking", - createdAt: "2026-09-17T12:00:00Z", - itemType: "reasoning" as const, - tone: "thinking" as const, - detail: "Check **ordering**.\nThen run the test.", - toolLifecycleStatus: "inProgress" as const, - }; - expect(workEntryRowLabel(entry)).toBe("Check **ordering**. Then run the test."); - expect(workEntryRowLabel(entry, true)).toBe("Thinking"); - expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought"); -}); - -it("caps one-line work-log labels for multi-kilobyte commands", () => { - const command = `cd /repo && python3 - <<'PY'\n${"print('x')\n".repeat(400)}PY`; - const entry = { - id: "heredoc", - label: "Ran command", - createdAt: "2026-09-17T12:00:00Z", - itemType: "command_execution" as const, - tone: "tool" as const, - command, - }; - for (const label of [workEntryRowLabel(entry), workEntryRowLabel(entry, true)]) { - expect(label.startsWith("cd /repo && python3 - <<'PY' print('x')")).toBe(true); - expect(label.length).toBeLessThanOrEqual(203); - } -}); - -it("keeps search output in expanded details rather than the compact label", () => { - const entry = { - id: "search", - label: "Grep", - toolTitle: "Grep", - createdAt: "2026-09-17T12:00:00Z", - itemType: "dynamic_tool" as const, - tone: "tool" as const, - detail: "---\nfile body", - toolData: {}, - }; - expect(workEntryRowLabel(entry)).toBe("Grep"); - expect(workEntryRowLabel(entry, true)).toBe("---\nfile body"); -}); - -it.each(["First paragraph.\n\nSecond paragraph.", ""])( - "previews live reasoning text %j", - (text) => { - const thought: OrchestrationV2TurnItem = { - ...base("live-thought", "2026-06-20T00:00:02.000Z", 1), - type: "reasoning", - status: "running", - completedAt: null, - streaming: true, - text, - }; - const feed = buildThreadFeed([projected(userMessage(), 0), projected(thought, 1)]); - const rows = deriveThreadFeedPresentation( - feed, - { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null }, - new Set(), - new Set(), - "2026-06-20T00:00:01.000Z", - ); - if (text) { - expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "Thinking", - thought: "First paragraph.", - live: true, - }); - } else { - expect( - rows.some( - (row) => - row.type === "thinking" || (row.type === "work-toggle" && row.summary === "Thinking"), - ), - ).toBe(true); - } - }, -); - -it("keeps the latest thought under the live tool status", () => { - const at = "2026-06-20T00:00:02.000Z"; - const thought: OrchestrationV2TurnItem = { - ...base("found-thought", at, 1), - type: "reasoning", - status: "completed", - streaming: false, - text: "Found the cause: no commits yet. Checking the UI next.", - }; - const tool = { ...command(at), status: "running" as const, completedAt: null }; - const rows = deriveThreadFeedPresentation( - buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), - { runId, status: "running", startedAt: at, completedAt: null }, - new Set(), - new Set(), - at, - ); - expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ - thought: "Found the cause: no commits yet.", - live: true, - }); - expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ - summary: "Thinking", - }); -}); - it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2772,44 +1349,6 @@ it("stops stranded thinking after a steer and follows the next thought or tool", expect(first.status).toBe("running"); }); -it("previews a settled thought in its collapsed header and labels its expanded header", () => { - const thought: OrchestrationV2TurnItem = { - ...base("thought-preview", "2026-06-20T00:00:02.000Z", 1), - type: "reasoning", - streaming: false, - text: "First paragraph.\n\nSecond paragraph.", - }; - const feed = buildThreadFeed([ - projected(userMessage(), 0), - projected(thought, 1), - projected(assistantMessage(), 2), - ]); - const run = { - runId, - status: "completed" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:03.000Z", - }; - const collapsed = deriveThreadFeedPresentation(feed, run, new Set([runId])); - const header = collapsed.find((row) => row.type === "work-toggle"); - expect(header).toMatchObject({ summary: "First paragraph. Second paragraph." }); - if (header?.type !== "work-toggle") throw new Error("Expected thought toggle"); - const expanded = deriveThreadFeedPresentation( - feed, - run, - new Set([runId]), - new Set([header.groupId]), - ); - expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "Thought", - continuesWorkLog: true, - }); - const detail = expanded.find((row) => row.type === "activity-group"); - expect(detail?.continuesWorkLog).toBeUndefined(); - if (detail?.type !== "activity-group") throw new Error("Expected full thought"); - expect(detail.activities[0]?.detail).toBe(thought.text); -}); - it.each(["provider_error", "usage_limit"] as const)( "keeps a historical %s failure and preceding work visible without disclosures", (failureClass) => { @@ -2869,118 +1408,6 @@ it.each(["provider_error", "usage_limit"] as const)( }, ); -describe("html renders", () => { - const page = { attachmentId: "attachment-page", title: "Revenue", height: 320 }; - const renderCall = ( - overrides: Partial> = {}, - ): OrchestrationV2TurnItem => ({ - ...base("item-render", "2026-06-20T00:00:02.500Z", 2), - type: "dynamic_tool", - toolName: "mcp__t3-code__html_render", - input: { title: page.title }, - output: { htmlRender: page }, - ...overrides, - }); - const laterCommand = { - ...command("2026-06-20T00:00:02.800Z"), - id: TurnItemId.make("item-command-later"), - ordinal: 3, - }; - const feed = () => - buildThreadFeed([ - projected(userMessage(), 0), - projected(command(), 1), - projected(renderCall(), 2), - projected(laterCommand, 3), - projected(assistantMessage("2026-06-20T00:00:04.000Z"), 4), - ]); - const latestRun = { - runId, - status: "completed" as const, - startedAt: "2026-06-20T00:00:01.000Z", - completedAt: "2026-06-20T00:00:04.000Z", - }; - - it("shows a completed render in place, outside the work log", () => { - const expanded = deriveThreadFeedPresentation(feed(), latestRun, new Set([runId])); - expect(expanded.map((entry) => entry.type)).toEqual([ - "message", - "run-fold", - "activity-group", - "html-render", - "activity-group", - "message", - ]); - expect(expanded[3]).toMatchObject({ type: "html-render", render: page, runId }); - // The render splits the work log, so each command is a lone call with its own group. - const callIds = [expanded[2], expanded[4]].map((row) => - row?.type === "activity-group" - ? row.activities.map((activity) => activity.projectedItem.item.id) - : [], - ); - expect(callIds).toEqual([["item-command"], ["item-command-later"]]); - const groupIds = [expanded[2], expanded[4]].map((row) => - row?.type === "activity-group" ? row.activities[0]?.groupStandIn?.groupId : undefined, - ); - expect(groupIds[0]).toBeDefined(); - expect(groupIds[1]).toBeDefined(); - expect(groupIds[0]).not.toBe(groupIds[1]); - }); - - it("keeps a render visible and in order when its run folds", () => { - const collapsed = deriveThreadFeedPresentation(feed(), latestRun, new Set()); - expect(collapsed.map((entry) => entry.type)).toEqual([ - "message", - "run-fold", - "html-render", - "message", - ]); - }); - - it("leaves running, failed and errored renders in the work log", () => { - for (const call of [ - renderCall({ status: "running", output: null }), - renderCall({ status: "failed" }), - renderCall({ output: { isError: true, htmlRender: page } }), - ]) { - const entries = buildThreadFeed([projected(call, 0)]); - expect(entries.map((entry) => entry.type)).toEqual(["activity-group"]); - } - }); -}); - -describe("MCP apps", () => { - const app = { - attachmentId: "attachment-app", - server: "weather", - tool: "get_weather", - resourceUri: "ui://weather/dashboard", - }; - const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({ - ...base("item-app", "2026-06-20T00:00:02.500Z", 2), - status, - type: "dynamic_tool", - toolName: "weather.get_weather", - input: { city: "Oslo" }, - output: { t3McpApp: app, result: { content: [] } }, - }); - const feed = (status: OrchestrationV2TurnItem["status"]) => - buildThreadFeed([ - projected(userMessage(), 0), - projected(appCall(status), 1), - projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2), - ]); - - it("hosts a completed app in place of its tool row, owned by its source item", () => { - const entry = feed("completed").find((candidate) => candidate.type === "mcp-app"); - expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId }); - }); - - it("keeps a running app call an ordinary work row", () => { - expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); - }); -}); - describe("streamed feed updates", () => { const runs = [RunId.make("run-1"), RunId.make("run-2"), RunId.make("run-3")] as const; const at = (second: number) => `2026-06-20T00:00:${String(second).padStart(2, "0")}.000Z`; @@ -3215,3 +1642,76 @@ describe("streamed feed updates", () => { expect(showsDelegation(steps[3]!)).toBe(false); }); }); + +describe("work rows relative to the thread workspace", () => { + const root = "/repo/wt"; + const activitiesOf = (feed: ReturnType) => + feed.flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + + it("drops a cd to the workspace and relativizes command paths", () => { + const item: OrchestrationV2TurnItem = { + ...base("workspace-command", "2026-06-20T00:00:02.000Z", 1), + type: "command_execution", + input: `/bin/zsh -lc 'cd ${root} && bun run test ${root}/src'`, + output: "ok", + exitCode: 0, + }; + const [activity] = activitiesOf(buildThreadFeed([projected(item, 0)], { workspaceRoot: root })); + + expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("bun run test src"); + // Copying keeps the command exactly as the agent ran it. + expect(activity?.getCopyText()).toContain(`cd ${root} && bun run test ${root}/src`); + + const [unscoped] = activitiesOf(buildThreadFeed([projected(item, 0)])); + expect(unscoped ? workEntryRowLabel(unscoped.workEntry) : null).toBe( + `cd ${root} && bun run test ${root}/src`, + ); + }); + + it("rebuilds cached path labels when the thread workspace changes", () => { + const item: OrchestrationV2TurnItem = { + ...base("cached-workspace-command", "2026-06-20T00:00:02.000Z", 1), + type: "command_execution", + input: `/bin/zsh -lc 'cd ${root} && bun run test ${root}/src'`, + output: "ok", + exitCode: 0, + }; + const rows = [projected(item, 0)]; + const cache = createThreadFeedCache(); + const [scoped] = activitiesOf(buildThreadFeed(rows, { workspaceRoot: root, cache })); + const [unscoped] = activitiesOf(buildThreadFeed(rows, { cache })); + expect(scoped ? workEntryRowLabel(scoped.workEntry) : null).toBe("bun run test src"); + expect(unscoped ? workEntryRowLabel(unscoped.workEntry) : null).toBe( + `cd ${root} && bun run test ${root}/src`, + ); + }); + + it("relativizes file tool targets but not prose rows", () => { + const change: OrchestrationV2TurnItem = { + ...base("workspace-change", "2026-06-20T00:00:02.000Z", 1), + type: "file_change", + fileName: `${root}/src/index.ts`, + }; + const read: OrchestrationV2TurnItem = { + ...base("workspace-read", "2026-06-20T00:00:03.000Z", 2), + type: "dynamic_tool", + toolName: "Read", + input: { file_path: `${root}/docs/guide.md` }, + }; + const notice: OrchestrationV2TurnItem = { + ...base("workspace-notice", "2026-06-20T00:00:04.000Z", 3), + type: "system_notice", + message: `Config reloaded from ${root}/t3.json`, + }; + const activities = activitiesOf( + buildThreadFeed([projected(change, 0), projected(read, 1), projected(notice, 2)], { + workspaceRoot: root, + }), + ); + + expect(activities[0]?.summary).toBe("Changed src/index.ts"); + expect(activities[0]?.workEntry.changedFiles).toEqual(["src/index.ts"]); + expect(workEntryRowLabel(activities[1]!.workEntry, false, root)).toBe("Read docs/guide.md"); + expect(activities[2]?.summary).toBe(`Config reloaded from ${root}/t3.json`); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8c90ed548e1d..6de27c1072c9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -4,6 +4,7 @@ import type { ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; +import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { webToolLabel, webToolView } from "@t3tools/client-runtime/work-log/web-tool"; @@ -155,6 +156,7 @@ export interface ThreadFeedMessage { readonly createdAt: string; readonly updatedAt: string; readonly projectedItem?: OrchestrationV2ProjectedTurnItem; + readonly checkpointSummary?: ThreadCheckpointSummary; } export interface LocalThreadMessage { @@ -201,7 +203,10 @@ type RawThreadFeedEntry = readonly app: McpAppReference; }; -export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; +export type ThreadFeedEntry = ThreadFeedEntryContent & { + readonly continuesWorkLog?: boolean; + readonly checkpointSummary?: ThreadCheckpointSummary; +}; type ThreadFeedEntryContent = | Extract @@ -506,6 +511,7 @@ function itemIsProminent(item: OrchestrationV2TurnItem): boolean { item.type === "model_change" || item.type === "thread_created" || item.type === "system_notice" || + item.type === "proposed_plan" || item.type === "secret_request" ); } @@ -919,20 +925,24 @@ function toFeedActivity( const readRange = readPaths && item.type === "dynamic_tool" ? toolReadRangeLabel(item.input) : null; const getFullDetail = memoizeValue(() => - readPaths - ? [...readPaths, ...(readRange ? [readRange] : [])].join("\n") || null - : item.type === "notification" - ? item.detail?.trim() || null - : formatItemFullDetail(row, item), + item.type === "proposed_plan" + ? item.markdown + : readPaths + ? [...readPaths, ...(readRange ? [readRange] : [])].join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); // Copies keep the exact text the agent ran or wrote. const getCopyText = memoizeValue(() => - [rawSummary, rawDetail, getFullDetail()] - .filter( - (value, index, values): value is string => - Boolean(value) && values.indexOf(value) === index, - ) - .join("\n"), + item.type === "proposed_plan" + ? `${item.markdown.trimEnd()}\n` + : [rawSummary, rawDetail, getFullDetail()] + .filter( + (value, index, values): value is string => + Boolean(value) && values.indexOf(value) === index, + ) + .join("\n"), ); return { id: `${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, @@ -1053,6 +1063,7 @@ function groupAdjacentActivities( } const isStandaloneActivity = + entry.activity.workEntry.questionAnswer !== undefined || entry.activity.projectedItem.item.type === "compaction" || entry.activity.projectedItem.item.type === "handoff" || entry.activity.projectedItem.item.type === "model_change" || @@ -1158,7 +1169,6 @@ function deriveThreadFeedRunFolds( runlessWorkActive: boolean, feedFailedRunIds: ReadonlySet, ): ReadonlyMap { - const firstAssistantMessageIdByRun = new Map(); const terminalAssistantMessageIdByRun = new Map(); const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); @@ -1213,9 +1223,6 @@ function deriveThreadFeedRunFolds( } group.entries.push(entry); if (entry.type === "message") { - if (!firstAssistantMessageIdByRun.has(runId)) { - firstAssistantMessageIdByRun.set(runId, entry.id); - } terminalAssistantMessageIdByRun.set(runId, entry.id); } if (entry.type !== "activity-group") continue; @@ -1245,13 +1252,11 @@ function deriveThreadFeedRunFolds( ) { continue; } - const firstAssistantId = firstAssistantMessageIdByRun.get(runId); const terminalAssistantId = terminalAssistantMessageIdByRun.get(runId); const hiddenEntryIds = new Set( group.entries .filter( (entry) => - entry.id !== firstAssistantId && entry.id !== terminalAssistantId && entry.type !== "html-render" && entry.type !== "mcp-app" && @@ -1260,6 +1265,7 @@ function deriveThreadFeedRunFolds( entry.activities.some( (activity) => activity.prominent || + activity.workEntry.questionAnswer !== undefined || activity.projectedItem.item.type === "notification" || activity.projectedItem.item.type === "handoff" || activity.projectedItem.item.type === "model_change" || @@ -1570,6 +1576,39 @@ export function deriveThreadFeedPresentation( ) { result.push(thinkingRow(activeWorkStartedAt, activeRunId)); } + const checkpointByRunId = new Map(); + for (const entry of sourceFeed) { + if (entry.type === "message" && entry.message.checkpointSummary) { + checkpointByRunId.set(entry.message.checkpointSummary.runId, entry.message.checkpointSummary); + } + } + const workRowByRunId = new Map(); + for (let index = tailStart; index < result.length; index += 1) { + const row = result[index]!; + if ( + (row.type === "run-fold" || row.type === "work-toggle") && + row.runId !== null && + checkpointByRunId.has(row.runId) && + (row.type === "run-fold" || !workRowByRunId.has(row.runId)) + ) { + workRowByRunId.set(row.runId, index); + } + } + for (const [runId, index] of workRowByRunId) { + result[index] = { ...result[index]!, checkpointSummary: checkpointByRunId.get(runId)! }; + } + for (let index = tailStart; index < result.length; index += 1) { + const row = result[index]!; + if ( + row.type !== "message" || + !row.message.checkpointSummary || + !workRowByRunId.has(row.message.checkpointSummary.runId) + ) + continue; + const message = { ...row.message }; + delete message.checkpointSummary; + result[index] = { ...row, message }; + } // Reused rows are final: the user message after them never continues a work log. for (let index = tailStart; index < result.length; index += 1) { const row = result[index]!; @@ -2005,6 +2044,7 @@ interface ThreadFeedBuild { readonly rows: ReadonlyArray; readonly attempts: ReadonlyArray | undefined; readonly nodes: ReadonlyArray | undefined; + readonly checkpointSummaries: ReadonlyArray | undefined; readonly foldedAnswerMessageIds: ReadonlySet; readonly delegatedChildrenByRun: ReadonlyMap>; readonly feed: ReadonlyArray; @@ -2034,6 +2074,30 @@ function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { return true; } +// Summaries are re-derived whenever messages change, including streamed text, +// so a new array often holds the same checkpoints. +function checkpointSummariesEqual( + left: ReadonlyArray | undefined, + right: ReadonlyArray | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined || left.length !== right.length) return false; + return left.every((summary, index) => { + const other = right[index]!; + return ( + summary.checkpointId === other.checkpointId && + summary.scopeId === other.scopeId && + summary.runId === other.runId && + summary.checkpointTurnCount === other.checkpointTurnCount && + summary.checkpointRef === other.checkpointRef && + summary.completedAt === other.completedAt && + summary.status === other.status && + summary.files === other.files && + summary.assistantMessageId === other.assistantMessageId + ); + }); +} + function setMapsEqual( left: ReadonlyMap>, right: ReadonlyMap>, @@ -2061,6 +2125,7 @@ export function buildThreadFeed( readonly attempts?: ReadonlyArray; readonly nodes?: ReadonlyArray; readonly workspaceRoot?: string | null; + readonly checkpointSummaries?: ReadonlyArray; readonly cache?: ThreadFeedCache; }, ): ThreadFeedEntry[] { @@ -2109,6 +2174,7 @@ export function buildThreadFeed( previous.attempts === options?.attempts && previous.nodes === options?.nodes && previous.workspaceRoot === workspaceRoot && + checkpointSummariesEqual(previous.checkpointSummaries, options?.checkpointSummaries) && setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) && setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun) ) { @@ -2263,8 +2329,18 @@ export function buildThreadFeed( if (insertionIndex === -1) entries.push(entry); else entries.splice(insertionIndex, 0, entry); } + const checkpointByMessageId = new Map( + (options?.checkpointSummaries ?? []) + .filter((checkpoint) => checkpoint.status === "ready" && checkpoint.files.length > 0) + .map((checkpoint) => [checkpoint.assistantMessageId, checkpoint]), + ); const grouped = groupAdjacentActivities( - entries, + entries.map((entry) => { + if (entry.type !== "message" || entry.message.role !== "assistant") return entry; + const checkpointSummary = checkpointByMessageId.get(entry.message.id); + if (!checkpointSummary || checkpointSummary.runId !== entry.message.runId) return entry; + return { ...entry, message: { ...entry.message, checkpointSummary } }; + }), delegatedChildrenByRun, // Anchored messages shift entries away from their rows; such a build is not reused. anchoredMessages.length > 0 @@ -2285,6 +2361,7 @@ export function buildThreadFeed( rows: visibleTurnItems, attempts: options?.attempts, nodes: options?.nodes, + checkpointSummaries: options?.checkpointSummaries, foldedAnswerMessageIds, delegatedChildrenByRun, feed, diff --git a/apps/mobile/src/lib/threadActivityInspector.test.ts b/apps/mobile/src/lib/threadActivityInspector.test.ts index 26c8df79ca47..5a1647f8330a 100644 --- a/apps/mobile/src/lib/threadActivityInspector.test.ts +++ b/apps/mobile/src/lib/threadActivityInspector.test.ts @@ -3,8 +3,6 @@ import { CheckpointId, CheckpointScopeId, NodeId, - ProviderDriverKind, - ProviderInstanceId, RunId, ThreadId, TurnItemId, @@ -55,143 +53,6 @@ function activityFor(item: OrchestrationV2TurnItem): Pick { - it("presents command lifecycle and exit state without cached raw output", () => { - const item: OrchestrationV2TurnItem = { - ...itemBase("command"), - type: "command_execution", - input: "vp check", - output: "all checks passed", - exitCode: 0, - }; - const support = { - ...EMPTY_V2_ITEM_SUPPORT, - item, - run: { status: "completed" } as never, - attempts: [ - { - id: "attempt-1", - attemptOrdinal: 1, - status: "superseded", - reason: "initial", - }, - { - id: "attempt-2", - attemptOrdinal: 2, - status: "completed", - reason: "steering_restart", - }, - ] as never, - node: { kind: "tool_call", status: "completed" } as never, - providerSession: { - status: "ready", - model: "gpt-5.4", - cwd: "/workspace/project", - } as never, - }; - - const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId); - expect(model.fields).toEqual( - expect.arrayContaining([ - { label: "Duration", value: "2.0s" }, - { label: "Run", value: "completed" }, - { label: "Working directory", value: "/workspace/project" }, - { label: "Visibility", value: "inherited" }, - ]), - ); - expect(model.blocks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: "Command", value: "vp check" }), - expect.objectContaining({ label: "Exit", value: "Process exited with code 0" }), - expect.objectContaining({ label: "Attempt history" }), - ]), - ); - expect(model.blocks.some((block) => block.label === "Output")).toBe(false); - expect(model.structuredDetails).not.toContain("all checks passed"); - }); - - it("exposes file and web result provenance plus dynamic structured data", () => { - const fileSearch: OrchestrationV2TurnItem = { - ...itemBase("file-search"), - type: "file_search", - pattern: "resolveSession", - results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }], - }; - const fileModel = buildThreadActivityInspector( - activityFor(fileSearch), - EMPTY_V2_ITEM_SUPPORT, - sourceThreadId, - ); - expect(fileModel.fileLinks).toEqual([ - { - label: "src/session.ts — function resolveSession()", - path: "src/session.ts", - line: 42, - }, - ]); - - const webSearch: OrchestrationV2TurnItem = { - ...itemBase("web-search"), - type: "web_search", - patterns: ["Effect Schema docs"], - results: [ - { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" }, - ], - }; - const webModel = buildThreadActivityInspector( - activityFor(webSearch), - EMPTY_V2_ITEM_SUPPORT, - sourceThreadId, - ); - expect(webModel.webLinks).toEqual([ - { label: "Schema", url: "https://effect.website/docs/schema" }, - ]); - expect(webModel.blocks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: "Schema", value: "Typed schemas" }), - ]), - ); - - const dynamicTool: OrchestrationV2TurnItem = { - ...itemBase("dynamic"), - type: "dynamic_tool", - toolName: "custom", - input: { nested: { value: 1 } }, - output: { ok: true }, - }; - const dynamicModel = buildThreadActivityInspector( - activityFor(dynamicTool), - EMPTY_V2_ITEM_SUPPORT, - sourceThreadId, - ); - expect(dynamicModel.blocks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ label: "Input", value: expect.stringContaining('"nested"') }), - ]), - ); - expect(dynamicModel.structuredDetails).toContain('"type": "dynamic_tool"'); - expect(dynamicModel.blocks.some((block) => block.label === "Output")).toBe(false); - expect(dynamicModel.structuredDetails).not.toContain('"output"'); - }); - - it("keeps file links and change counts without cached patch bodies", () => { - const item: OrchestrationV2TurnItem = { - ...itemBase("file"), - type: "file_change", - fileName: "src/example.ts", - additions: 2, - deletions: 1, - diffStr: "RAW_PATCH", - oldStr: "RAW_BEFORE", - newStr: "RAW_AFTER", - }; - const model = buildThreadActivityInspector(activityFor(item), EMPTY_V2_ITEM_SUPPORT, threadId); - expect(model.fileLinks).toEqual([{ label: `modify ${item.fileName}`, path: item.fileName }]); - expect(model.fields).toContainEqual({ label: "Changes", value: "+2 −1" }); - expect(model.blocks).toEqual([]); - expect(model.structuredDetails).not.toContain("RAW_"); - expect(model.structuredDetails).toContain(item.fileName); - }); - it("enables rollback only for the matching ready checkpoint", () => { const checkpointId = CheckpointId.make("checkpoint-1"); const scopeId = CheckpointScopeId.make("scope-1"); @@ -232,43 +93,4 @@ describe("buildThreadActivityInspector", () => { expect(inheritedModel.canRollback).toBe(false); expect(inheritedModel.rollbackTarget).toBeNull(); }); - - it("prefers live subagent progress from supporting state", () => { - const item: OrchestrationV2TurnItem = { - ...itemBase("subagent"), - type: "subagent", - subagentId: nodeId, - origin: "provider_native", - driver: ProviderDriverKind.make("claude"), - providerInstanceId: ProviderInstanceId.make("claude"), - childThreadId: null, - prompt: "Audit the reducer", - progress: "Starting", - result: null, - }; - const model = buildThreadActivityInspector( - activityFor(item), - { - ...EMPTY_V2_ITEM_SUPPORT, - item, - subagent: { - origin: "provider_native", - status: "running", - progress: "Reading projection tests", - result: null, - } as never, - }, - sourceThreadId, - ); - - expect(model.fields).toContainEqual({ - label: "Delegated task", - value: "provider native · running", - }); - expect(model.blocks).toContainEqual({ - label: "Progress", - value: "Reading projection tests", - monospaced: false, - }); - }); }); diff --git a/apps/mobile/src/lib/uniwind-dev-refresh.test.ts b/apps/mobile/src/lib/uniwind-dev-refresh.test.ts deleted file mode 100644 index 852bf1f04d9f..000000000000 --- a/apps/mobile/src/lib/uniwind-dev-refresh.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import * as NodeURL from "node:url"; - -vi.mock("react-native", () => ({ - Appearance: { - addChangeListener: vi.fn(), - getColorScheme: () => "light", - setColorScheme: vi.fn(), - }, - Platform: { constants: {}, OS: "ios" }, -})); - -vi.mock("../../node_modules/uniwind/src/core/listener", () => ({ - UniwindListener: { notify() {}, notifyAll() {} }, -})); - -vi.mock("../../node_modules/uniwind/src/core/native", () => ({ - UniwindStore: { - reinit: (generateStyleSheetCallback: () => unknown) => { - generateStyleSheetCallback(); - }, - runtime: { currentThemeName: "light", insets: {} }, - vars: {}, - }, -})); - -const loadUniwind = async () => { - const modulePath = NodeURL.fileURLToPath( - new URL("../../node_modules/uniwind/src/core/config/config.native.ts", import.meta.url), - ); - const { Uniwind } = (await import(/* @vite-ignore */ modulePath)) as { - Uniwind: { readonly themes: Array }; - }; - return Uniwind as typeof Uniwind & { - __reinit: (initialize: () => unknown, themes: Array, fingerprint?: string) => void; - }; -}; - -describe("Uniwind native stylesheet refresh", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - vi.stubGlobal("__DEV__", true); - }); - - it("initializes once for identical generated styles", async () => { - const Uniwind = await loadUniwind(); - const initialize = vi.fn(() => ({})); - - Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); - Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); - - expect(initialize).toHaveBeenCalledTimes(1); - }); - - it("reinitializes for changed generated styles and themes", async () => { - const Uniwind = await loadUniwind(); - const initialize = vi.fn(() => ({})); - - Uniwind.__reinit(initialize, ["light", "dark"], "before"); - Uniwind.__reinit(initialize, ["light", "dark"], "after"); - Uniwind.__reinit(initialize, ["light", "dark", "dim"], "themes-with-dim"); - - expect(initialize).toHaveBeenCalledTimes(3); - expect(Uniwind.themes).toEqual(["light", "dark", "dim"]); - }); - - it("retries the same output after initialization fails", async () => { - const Uniwind = await loadUniwind(); - const initialize = vi - .fn() - .mockImplementationOnce(() => { - throw new Error("initialization failed"); - }) - .mockImplementationOnce(() => ({})); - - expect(() => Uniwind.__reinit(initialize, ["light", "dark"], "retry-output")).toThrow( - "initialization failed", - ); - Uniwind.__reinit(initialize, ["light", "dark"], "retry-output"); - - expect(initialize).toHaveBeenCalledTimes(2); - }); - - it("keeps no-fingerprint and production reinitialization semantics", async () => { - const Uniwind = await loadUniwind(); - const initialize = vi.fn(() => ({})); - - Uniwind.__reinit(initialize, ["light", "dark"]); - Uniwind.__reinit(initialize, ["light", "dark"]); - vi.stubGlobal("__DEV__", false); - Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); - Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); - - expect(initialize).toHaveBeenCalledTimes(4); - }); -}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs deleted file mode 100644 index 3820585bcb56..000000000000 --- a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs +++ /dev/null @@ -1,175 +0,0 @@ -"use strict"; - -// Loading `dist/common` needs a small custom loader because uniwind declares -// `"type": "module"` over that CommonJS output and its internal `@/...` import -// alias only exists at uniwind's own build time; the loader hands unmodified -// file bytes to V8 and resolves those aliases the same way, so this runs the -// exact compiled code the Metro transformer bundles. -const fs = require("node:fs"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -function createUniwindCompilerLoader(uniwindPackageJson) { - const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson)); - const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json")); - const cache = new Map(); - - const load = (absoluteFileWithoutExtension) => { - const resolved = [ - absoluteFileWithoutExtension, - `${absoluteFileWithoutExtension}.js`, - path.join(absoluteFileWithoutExtension, "index.js"), - ].find((candidate) => { - try { - return fs.statSync(candidate).isFile(); - } catch { - return false; - } - }); - if (resolved === undefined) { - throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`); - } - const cached = cache.get(resolved); - if (cached !== undefined) return cached; - - const cjsModule = { exports: {} }; - - const localRequire = (request) => { - if (request.startsWith("@/")) { - return load(path.join(uniwindRoot, "dist/common", request.slice(2))); - } - if (request.startsWith(".")) { - return load(path.resolve(path.dirname(resolved), request)); - } - return uniwindRequire(request); - }; - - const code = fs.readFileSync(resolved, "utf8"); - new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports); - cache.set(resolved, cjsModule.exports); - return cjsModule.exports; - }; - - const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request)); - return { loadBundler, uniwindRequire, uniwindRoot }; -} - -async function main() { - const fixtureDir = fs.realpathSync(path.resolve(process.argv[2])); - const uniwindPackageJson = require.resolve("uniwind/package.json", { - paths: [fixtureDir], - }); - const { loadBundler, uniwindRequire, uniwindRoot } = - createUniwindCompilerLoader(uniwindPackageJson); - const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js"); - const { addMetaToStylesTemplate } = loadBundler( - "bundler/css-processor/addMetaToStylesTemplate.js", - ); - const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js"); - - const { compile } = uniwindRequire("@tailwindcss/node"); - const { Scanner } = uniwindRequire("@tailwindcss/oxide"); - const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), { - base: fixtureDir, - onDependency: () => {}, - }); - const scanner = new Scanner({ - sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }], - }); - const tailwindCSS = await compiler.build(scanner.scan()); - - const platforms = {}; - const compiledGuardedClasses = {}; - for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) { - compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true; - } - const platformBlock = (name) => - new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? ""; - const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length; - const tailwindChecks = { - iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length, - androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length, - iosUtilities: utilityCountIn(platformBlock("ios")), - androidUtilities: utilityCountIn(platformBlock("android")), - }; - for (const platform of ["ios", "android"]) { - const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } }; - const processor = new ProcessorBuilder(bundlerConfig); - processor.transform(tailwindCSS); - const compiled = addMetaToStylesTemplate(processor, platform); - const payload = compileNativeCSS(bundlerConfig, tailwindCSS); - const styles = {}; - // Compiled entries keep only their generated `matches` predicate; the - // conditions they were built from live on the processor's style metadata. - for (const [className, entries] of Object.entries(compiled)) { - styles[className] = entries.map(({ index }) => { - const { - platform: guard, - conditions, - active, - focus, - disabled, - dataAttributes, - } = processor.stylesheets[className][index].meta; - return { - platform: guard, - responsive: conditions.some((condition) => condition.includes("rt.screen.width")), - active, - focus, - disabled, - dataAttributes, - }; - }); - } - platforms[platform] = { - styles, - payloadIncludesAllCompiled: Object.keys(compiled).every((className) => - payload.includes(`"${className}"`), - ), - payloadLeaks: Object.keys(compiledGuardedClasses) - .filter((className) => compiled[className] === undefined) - .filter((className) => payload.includes(`"${className}"`)), - }; - } - - const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs"); - let reinitCode = ""; - const Module = require("node:module"); - const origLoad = Module._load; - Module._load = function (request, ...rest) { - if (request === "metro-transform-worker") { - return { - transform: async (_config, _root, _file, data) => { - reinitCode = data.toString(); - return { output: [{ data: {} }] }; - }, - }; - } - return origLoad.call(this, request, ...rest); - }; - process.chdir(fixtureDir); - try { - const { transform } = require(transformerPath); - await transform( - { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } }, - fixtureDir, - "global.css", - Buffer.from(""), - { platform: "ios", type: "module" }, - ); - } finally { - Module._load = origLoad; - } - const transformerCheck = { - reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"), - fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode), - themesArg: reinitCode.includes("['light', 'dark']"), - }; - - process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms })); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts deleted file mode 100644 index 6525f10c440f..000000000000 --- a/apps/mobile/src/lib/uniwind-platform-variants.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { beforeAll, describe, expect, it } from "vite-plus/test"; - -interface CompiledStyle { - platform: string | null; - responsive: boolean; - active: boolean | null; - focus: boolean | null; - disabled: boolean | null; - dataAttributes: Record | null; -} - -interface FixtureOutput { - tailwindChecks: { - iosBlocks: number; - androidBlocks: number; - iosUtilities: number; - androidUtilities: number; - }; - transformerCheck: { - reinitPayload: boolean; - fingerprintArg: boolean; - themesArg: boolean; - }; - platforms: Record< - string, - { - styles: Record; - payloadIncludesAllCompiled: boolean; - payloadLeaks: string[]; - } - >; -} - -const FIXTURE_SOURCE = `export const Probe = () => ( -
-); -`; - -const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; -const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; -const SHARED_CLASSES = ["flex-1", "pt-12"]; -const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; - -const runFixture = (globalCss: string, probeSource: string): FixtureOutput => { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-")); - try { - // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app. - NodeFS.symlinkSync( - NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)), - NodePath.join(tempDir, "node_modules"), - "dir", - ); - NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss); - NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource); - - const fixture = NodeURL.fileURLToPath( - new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url), - ); - const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], { - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - timeout: 60_000, - }); - // The fixture's compiler may log warnings to stdout before the JSON payload. - const start = stdout.indexOf('{"tailwindChecks"'); - if (start === -1) { - throw new Error(`Fixture produced no JSON:\n${stdout}`); - } - return JSON.parse(stdout.slice(start)) as FixtureOutput; - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } -}; - -describe("uniwind platform variants compile per platform", () => { - let output: FixtureOutput; - - beforeAll(() => { - output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE); - }, 60_000); - - it("compiles the fixture utilities into shared platform blocks", () => { - expect(output.tailwindChecks).toEqual({ - iosBlocks: 1, - androidBlocks: 1, - iosUtilities: 3, - androidUtilities: 3, - }); - }); - - it("keeps ios: utilities out of the android bundle and guards them on ios", () => { - for (const className of IOS_CLASSES) { - expect( - output.platforms.android?.styles[className], - `android has ${className}`, - ).toBeUndefined(); - expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined(); - expect( - output.platforms.ios?.styles[className]?.every((style) => style.platform === "ios"), - ).toBe(true); - } - }); - - it("keeps android: utilities out of the ios bundle and guards them on android", () => { - for (const className of ANDROID_CLASSES) { - expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined(); - expect( - output.platforms.android?.styles[className], - `android lacks ${className}`, - ).toBeDefined(); - expect( - output.platforms.android?.styles[className]?.every((style) => style.platform === "android"), - ).toBe(true); - } - }); - - it("keeps shared and responsive utilities usable on both platforms", () => { - for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) { - for (const platform of ["ios", "android"]) { - const styles = output.platforms[platform]?.styles[className]; - expect(styles, `${platform} lacks ${className}`).toBeDefined(); - expect(styles?.every((style) => style.platform === null)).toBe(true); - } - } - for (const platform of ["ios", "android"]) { - for (const className of RESPONSIVE_CLASSES) { - const style = output.platforms[platform]?.styles[className]?.at(-1); - expect(style?.responsive, `${className} lost its breakpoint`).toBe(true); - } - } - }); - - it("serializes every compiled style into the platform payload and leaks nothing", () => { - for (const platform of ["ios", "android"]) { - expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true); - expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]); - } - }); -}); - -describe("uniwind keeps media rules nested inside class rules attached to the class", () => { - let nested: FixtureOutput; - - beforeAll(() => { - nested = runFixture( - [ - '@import "tailwindcss";', - '@import "uniwind";', - "", - ".container-x {", - " width: 100%;", - " @media (width >= 40rem) {", - " max-width: 40rem;", - " }", - "}", - "", - "@utility foo-x {", - " padding: 2px;", - " @variant ios {", - " padding: 3px;", - " }", - "}", - "", - ].join("\n"), - 'export const Probe = () =>
;\n', - ); - }, 60_000); - - it("keeps the nested breakpoint entry of a nested media rule", () => { - for (const platform of ["ios", "android"]) { - const entries = nested.platforms[platform]?.styles["container-x"]; - expect(entries, `${platform} lacks container-x`).toBeDefined(); - expect(entries?.some((style) => style.platform === null && !style.responsive)).toBe(true); - expect(entries?.some((style) => style.platform === null && style.responsive)).toBe(true); - } - }); - - it("keeps @variant declarations inside @utility on their platform", () => { - const iosEntries = nested.platforms.ios?.styles["foo-x"]; - expect(iosEntries?.some((style) => style.platform === null)).toBe(true); - expect(iosEntries?.some((style) => style.platform === "ios")).toBe(true); - - const androidEntries = nested.platforms.android?.styles["foo-x"]; - expect(androidEntries?.some((style) => style.platform === null)).toBe(true); - expect(androidEntries?.some((style) => style.platform !== null)).toBe(false); - }); -}); - -describe("uniwind keeps selector and transformer behavior", () => { - let output: FixtureOutput; - - beforeAll(() => { - output = runFixture( - '@import "tailwindcss";\n@import "uniwind";\n', - 'export const Probe = () =>
;\n', - ); - }, 60_000); - - it("keeps state and data variants conditioned, and rejects unsupported compounds", () => { - for (const platform of ["ios", "android"]) { - const styles = output.platforms[platform]?.styles; - expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true); - expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true); - expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe( - true, - ); - // `[aria-disabled="true"]` is not expressible at runtime; emitting it - // unconditionally (the pre-#9355 behavior) styles the element always. - expect(styles).not.toHaveProperty("aria-disabled:text-red-500"); - } - }); - - it("emits the global.css virtual module with a native styles fingerprint", () => { - expect(output.transformerCheck).toEqual({ - reinitPayload: true, - fingerprintArg: true, - themesArg: true, - }); - }); -}); diff --git a/apps/mobile/src/lib/videoAudioSession.test.ts b/apps/mobile/src/lib/videoAudioSession.test.ts deleted file mode 100644 index 7dc2c9dabc69..000000000000 --- a/apps/mobile/src/lib/videoAudioSession.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - platform: { OS: "ios" as "ios" | "android" }, - release: vi.fn(async (): Promise => {}), -})); -vi.mock("react-native", () => ({ Platform: mocks.platform })); -vi.mock("expo", () => ({ - requireOptionalNativeModule: () => ({ releaseVideoAudioSession: mocks.release }), -})); - -let session: typeof import("./videoAudioSession"); - -beforeEach(async () => { - vi.resetModules(); - vi.useFakeTimers(); - mocks.platform.OS = "ios"; - mocks.release.mockClear().mockResolvedValue(undefined); - session = await import("./videoAudioSession"); -}); - -afterEach(() => vi.useRealTimers()); - -describe("video audio session", () => { - it("releases once, after the last playing player stops, unless playback restarts first", () => { - session.mediaPlaybackStarted(); - session.mediaPlaybackStarted(); - session.mediaPlaybackStopped(); - vi.advanceTimersByTime(1_000); - expect(mocks.release).not.toHaveBeenCalled(); - - session.mediaPlaybackStopped(); - session.mediaPlaybackStarted(); - vi.advanceTimersByTime(1_000); - expect(mocks.release).not.toHaveBeenCalled(); - - session.mediaPlaybackStopped(); - vi.advanceTimersByTime(1_000); - expect(mocks.release).toHaveBeenCalledTimes(1); - }); - - it("stays fullscreen until every overlapping presentation closes", () => { - const listener = vi.fn(); - session.subscribeFullscreenVideo(listener); - session.fullscreenVideoOpened(); - session.fullscreenVideoOpened(); - session.fullscreenVideoClosed(); - expect(session.isFullscreenVideoOpen()).toBe(true); - session.fullscreenVideoClosed(); - expect(session.isFullscreenVideoOpen()).toBe(false); - expect(listener).toHaveBeenCalledTimes(4); - }); -}); diff --git a/apps/mobile/src/lib/videoThumbnails.test.ts b/apps/mobile/src/lib/videoThumbnails.test.ts index e577e448ea2a..155a906143a2 100644 --- a/apps/mobile/src/lib/videoThumbnails.test.ts +++ b/apps/mobile/src/lib/videoThumbnails.test.ts @@ -21,23 +21,6 @@ beforeEach(async () => { afterEach(() => vi.useRealTimers()); describe("video thumbnails", () => { - it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { - const file = source(); - const resolveSource = vi.fn(async () => file); - const signal = new AbortController().signal; - const results = await Promise.all([ - thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), - thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), - ]); - expect(results).toEqual([frame, frame]); - expect(resolveSource).toHaveBeenCalledTimes(1); - expect(mocks.createPlayer).toHaveBeenCalledTimes(1); - expect(file.dispose).toHaveBeenCalledTimes(1); - const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); - expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); - expect(refreshed).not.toHaveBeenCalled(); - }); - it("serializes decoding and skips queued requests that scroll out of view", async () => { const started = Promise.withResolvers(); const generated = Promise.withResolvers<(typeof frame)[]>(); @@ -96,73 +79,4 @@ describe("video thumbnails", () => { expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); }); - - it("releases failed extractions and permits a later retry", async () => { - const broken = player(); - broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); - mocks.createPlayer.mockReturnValueOnce(broken); - const file = source(); - expect( - await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), - ).toBeNull(); - expect(broken.release).toHaveBeenCalledTimes(1); - expect(file.dispose).toHaveBeenCalledTimes(1); - expect( - await thumbnails.loadVideoThumbnail( - "retry", - async () => source(), - new AbortController().signal, - ), - ).toBe(frame); - }); - - it("does not let an unreachable source block the queue indefinitely", async () => { - vi.useFakeTimers(); - const started = Promise.withResolvers(); - const first = player(); - first.replaceAsync.mockImplementation(() => { - started.resolve(); - return new Promise(() => {}); - }); - mocks.createPlayer.mockReturnValueOnce(first); - const file = source(); - const request = thumbnails.loadVideoThumbnail( - "unreachable", - async () => file, - new AbortController().signal, - ); - await started.promise; - await vi.advanceTimersByTimeAsync(15_000); - expect(await request).toBeNull(); - expect(first.release).toHaveBeenCalledTimes(1); - expect(file.dispose).toHaveBeenCalledTimes(1); - expect( - await thumbnails.loadVideoThumbnail( - "reachable", - async () => source(), - new AbortController().signal, - ), - ).toBe(frame); - }); - - it("bounds the retained native images without invalidating frames still displayed", async () => { - for (let i = 0; i < 33; i++) { - await thumbnails.loadVideoThumbnail( - `clip:${i}`, - async () => source(), - new AbortController().signal, - ); - } - expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); - expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); - expect(mocks.createPlayer).toHaveBeenCalledTimes(33); - expect( - await thumbnails.loadVideoThumbnail( - "clip:0", - async () => source(), - new AbortController().signal, - ), - ).toBe(frame); - expect(mocks.createPlayer).toHaveBeenCalledTimes(34); - }); }); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts index 4f745aed8851..a1028270df97 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.test.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.test.ts @@ -10,13 +10,6 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false); }); - it("detects fenced code blocks", () => { - expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true); - expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true); - expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true); - expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true); - }); - it("detects indented code blocks", () => { const prompt = 'before\n\n def search(x):\n return x\n\n"""\n\nafter'; expect(hasWideMarkdownBlock(prompt)).toBe(true); @@ -30,39 +23,4 @@ describe("hasWideMarkdownBlock", () => { expect(hasWideMarkdownBlock("before\n \nafter")).toBe(false); expect(hasWideMarkdownBlock("before\n \t\nafter")).toBe(false); }); - - it("detects top-level and blockquoted ordered-list markers", () => { - expect(hasWideMarkdownBlock("1. One\n2. Two\n3. Three\n4. Four\n5. Five")).toBe(true); - expect(hasWideMarkdownBlock("before\n3) Three")).toBe(true); - expect(hasWideMarkdownBlock("> 1. One\n> 2. Two")).toBe(true); - expect(hasWideMarkdownBlock("> > 3) Three")).toBe(true); - }); - - it("detects nested ordered lists", () => { - expect(hasWideMarkdownBlock("- Parent\n 1. Child\n 2. Child")).toBe(true); - expect(hasWideMarkdownBlock("> - Parent\n> 1. Child")).toBe(true); - }); - - it("can limit ordered-list width pinning to Android", () => { - const orderedList = "1. One\n2. Two"; - expect(hasWideMarkdownBlock(orderedList, { includeOrderedLists: true })).toBe(true); - expect(hasWideMarkdownBlock(orderedList, { includeOrderedLists: false })).toBe(false); - expect(hasWideMarkdownBlock("```\ncode\n```", { includeOrderedLists: false })).toBe(true); - expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |", { includeOrderedLists: false })).toBe( - true, - ); - }); - - it("detects blockquotes only when the native renderer needs width pinning", () => { - expect(hasWideMarkdownBlock("> quoted", { includeBlockquotes: true })).toBe(true); - expect(hasWideMarkdownBlock(" > quoted", { includeBlockquotes: true })).toBe(true); - expect(hasWideMarkdownBlock("> quoted")).toBe(false); - expect(hasWideMarkdownBlock("prose > quoted", { includeBlockquotes: true })).toBe(false); - expect(hasWideMarkdownBlock(" > indented code", { includeBlockquotes: true })).toBe(true); - }); - - it("detects GFM tables", () => { - expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true); - expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true); - }); }); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 1e29dd84282a..686933abf09a 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -7,6 +7,7 @@ import { import { markdownWithHtml, markdownWithObjectLinkLabels, + markdownWithInlineFileLinks, markdownWithTableBreaks, } from "../lib/markdownDocument"; import { useCallback, useMemo, useState, type ReactNode } from "react"; @@ -155,7 +156,15 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const { renderImage, resolveImageSource } = props; const { themeAppearance, mathRenderingEnabled } = useAppearancePreferences(); const { markdown, urls: objectLinkUrls } = useMemo( - () => markdownWithObjectLinkLabels(markdownWithTableBreaks(markdownWithHtml(props.markdown))), + () => + markdownWithObjectLinkLabels( + markdownWithTableBreaks( + markdownWithInlineFileLinks( + markdownWithHtml(props.markdown), + ENRICHED_INLINE_FILE_LINK_REGEX, + ), + ), + ), [props.markdown], ); const [documentAssets, setDocumentAssets] = useState([]); @@ -182,7 +191,6 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { const linkRecognition = useMemo( () => ({ text: enrichedSkillLinkRegex(props.skills) ?? undefined, - inlineCode: ENRICHED_INLINE_FILE_LINK_REGEX, }), [props.skills], ); diff --git a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx index 41d8777f6dfb..3c522bf7dc6b 100644 --- a/apps/mobile/src/native/NativeHeaderToolbar.test.tsx +++ b/apps/mobile/src/native/NativeHeaderToolbar.test.tsx @@ -1,5 +1,5 @@ import type { NativeStackHeaderItem } from "@react-navigation/native-stack"; -import { act } from "react"; +import { act, createContext, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, expect, it, vi } from "vite-plus/test"; @@ -56,6 +56,43 @@ function renderDocument(path: string, placement: Placement) { act(() => root!.render()); } +// Like React Navigation's NavigationStateContext, whose default value throws when +// read. React restores a context's default outside render, so layout effects see it. +const ContainerStateContext = createContext({ + get getIsInitial(): never { + throw new Error("Couldn't find a navigation context."); + }, +}); +const containerState = {}; + +function ConnectingToolbar(props: { readonly label: string }) { + return ( + + + + + + ); +} + +function renderConnecting(label: string) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root ??= createRoot(document.createElement("div")); + act(() => + root!.render( + + + , + ), + ); +} + +function renderedCustomLabel() { + const item = harness.renderedItems[0]; + if (item?.type !== "custom") throw new Error("Expected the custom header element"); + return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"]; +} + function openRenderedDocument() { const menu = harness.renderedItems[0]; if (menu?.type !== "menu") throw new Error("Expected the native file menu"); @@ -85,16 +122,14 @@ it.each(["left", "right", "bottom"] as const)( }, ); -it.each(["left", "right", "bottom"] as const)( - "does not reapply %s header options for equivalent document renders", - (placement) => { - renderDocument("first/guide.pdf", placement); - const applied = harness.setOptionsCalls; - renderDocument("first/guide.pdf", placement); - renderDocument("first/guide.pdf", placement); +it("applies a custom header element rendered inside a navigation container", () => { + renderConnecting("Connecting"); + expect(renderedCustomLabel()).toBe("Connecting"); - expect(harness.setOptionsCalls).toBe(applied); - openRenderedDocument(); - expect(harness.openedDocuments).toEqual(["first/guide.pdf"]); - }, -); + const applied = harness.setOptionsCalls; + renderConnecting("Connecting"); + expect(harness.setOptionsCalls).toBe(applied); + + renderConnecting("Reconnecting"); + expect(renderedCustomLabel()).toBe("Reconnecting"); +}); diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index e1a133e57bc3..a0af8585bc63 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -10,7 +10,6 @@ export interface NativeMarkdownTextStyle { readonly codeBackgroundColor: string; readonly codeBlockBackgroundColor: string; readonly fileTextColor: string; - readonly skillTextColor: string; readonly quoteMarkerColor: string; readonly dividerColor: string; readonly contextChipBorderColor?: string; @@ -64,6 +63,7 @@ export interface MarkdownLinkCustomization { readonly color?: string; readonly label?: string; readonly icon?: import("react-native").ImageSourcePropType; + readonly chip?: { readonly accent?: string; readonly tintsIcon: boolean }; } export interface SelectableMarkdownTextProps { diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 39217dbc5e8b..72b30ab1253e 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -4,7 +4,7 @@ import type { NativeStackNavigationOptions, NativeStackNavigationProp, } from "@react-navigation/native-stack"; -import { use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { isValidElement, use, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context"; import type { ColorValue } from "react-native"; @@ -55,6 +55,29 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +// React tags elements, component types and contexts with `$$typeof`; their other +// fields are React internals. In development an element's `_owner` is a fiber that +// reaches the whole tree, including contexts that hold their default value outside +// render, and React Navigation's NavigationStateContext default throws when read. +function isReactObject(value: object): boolean { + return "$$typeof" in value; +} + +const identityIds = new WeakMap(); +let nextIdentityId = 0; + +function identitySignature(value: unknown): string { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + return String(value); + } + let id = identityIds.get(value); + if (id === undefined) { + id = nextIdentityId++; + identityIds.set(value, id); + } + return `identity:${id}`; +} + function optionsSignature(value: unknown, seen = new WeakSet()): string { if (value === null) return "null"; switch (typeof value) { @@ -75,6 +98,11 @@ function optionsSignature(value: unknown, seen = new WeakSet()): string return `bigint:${String(value)}`; case "object": { const object = value as object; + if (isValidElement(object)) { + // Elements are recreated on every render, so compare what they render. + return `element:${identitySignature(object.type)}:${String(object.key)}:${optionsSignature(object.props, seen)}`; + } + if (isReactObject(object)) return identitySignature(object); if (seen.has(object)) return "[circular]"; seen.add(object); if (Array.isArray(value)) { @@ -121,7 +149,7 @@ function stabilizeOptionFunctions( ); } if (value !== null && typeof value === "object") { - if (seen.has(value) || "current" in value) return value; + if (seen.has(value) || "current" in value || isReactObject(value)) return value; seen.add(value); return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 3557c34d9dd7..17c80d862929 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -7,9 +7,13 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } f import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { StyleSheet } from "react-native"; -import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons"; -import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; -import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; +import { + composerChipSizeSuffix, + contextChipIcon, + contextChipPresentation, + contextRecordFileName, +} from "../lib/composerChipPresentation"; +import { contextChipIconUri } from "../lib/contextChipIconSource"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { createNativeComposerTheme } from "../lib/nativeComposerTheme"; import { FONT_FAMILIES } from "../lib/fontFamilies"; @@ -91,6 +95,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: (event: NativeSyntheticEvent<{ alternate: boolean }>) => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -100,10 +107,6 @@ function basename(path: string): string { return separator >= 0 ? path.slice(separator + 1) : path; } -function fileIconUri(path: string): string | null { - return markdownIconAssetUri(markdownFileIconSource(resolveMarkdownFileIcon(path))) ?? null; -} - export function ComposerEditor({ ref, skills = EMPTY_SKILLS, @@ -117,6 +120,7 @@ export function ComposerEditor({ onFocus, onBlur, onSubmit, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -156,12 +160,20 @@ export function ComposerEditor({ token.type === "context" ? props.context?.records.find((record) => record.contextId === token.contextId) : undefined; + const presentation = contextChipPresentation( + token.type === "context" ? token.kind : token.type, + record, + ); + const icon = contextChipIcon( + presentation, + token.type === "mention" ? token.value : contextRecordFileName(record), + ); return { type: token.type, source: token.source, start: token.start, end: token.end, - ...contextChipPresentation(token.type === "context" ? token.kind : token.type, record), + accent: presentation.accent, label: token.type === "skill" ? (skillLabels.get(token.value) ?? token.value) @@ -169,12 +181,8 @@ export function ComposerEditor({ ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", - iconUri: - token.type === "mention" - ? fileIconUri(token.value) - : record?.kind === "mention" && "path" in record - ? fileIconUri(record.path) - : null, + iconUri: contextChipIconUri(icon), + tintsIcon: icon.kind === "chip", }; }), ); @@ -342,6 +350,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } onComposerSubmit={ onSubmit === undefined ? undefined diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 6e950a406ccb..da32beb2683a 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -8,9 +8,13 @@ import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } f import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Platform, StyleSheet } from "react-native"; -import { markdownFileIconSource, markdownIconAssetUri } from "../lib/markdownFileIcons"; -import { composerChipSizeSuffix, contextChipPresentation } from "../lib/composerChipPresentation"; -import { resolveMarkdownFileIcon } from "../lib/markdownLinks"; +import { + composerChipSizeSuffix, + contextChipIcon, + contextChipPresentation, + contextRecordFileName, +} from "../lib/composerChipPresentation"; +import { contextChipIconUri } from "../lib/contextChipIconSource"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { FONT_FAMILIES } from "../lib/fontFamilies"; @@ -90,6 +94,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -99,10 +106,6 @@ function basename(path: string): string { return separator >= 0 ? path.slice(separator + 1) : path; } -function fileIconUri(path: string): string | null { - return markdownIconAssetUri(markdownFileIconSource(resolveMarkdownFileIcon(path))) ?? null; -} - export function ComposerEditor({ ref, skills = EMPTY_SKILLS, @@ -115,6 +118,7 @@ export function ComposerEditor({ onPasteText, onFocus, onBlur, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -154,12 +158,20 @@ export function ComposerEditor({ token.type === "context" ? props.context?.records.find((record) => record.contextId === token.contextId) : undefined; + const presentation = contextChipPresentation( + token.type === "context" ? token.kind : token.type, + record, + ); + const icon = contextChipIcon( + presentation, + token.type === "mention" ? token.value : contextRecordFileName(record), + ); return { type: token.type, source: token.source, start: token.start, end: token.end, - ...contextChipPresentation(token.type === "context" ? token.kind : token.type, record), + accent: presentation.accent, label: token.type === "skill" ? (skillLabels.get(token.value) ?? token.value) @@ -167,12 +179,8 @@ export function ComposerEditor({ ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", - iconUri: - token.type === "mention" - ? fileIconUri(token.value) - : record?.kind === "mention" && "path" in record - ? fileIconUri(record.path) - : null, + iconUri: contextChipIconUri(icon), + tintsIcon: icon.kind === "chip", }; }), ); @@ -347,6 +355,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } /> ); diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 20554b4f1eb0..d621deb3c6ac 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -19,6 +19,7 @@ export function ComposerEditor({ singleLineCentered: _singleLineCentered, enterBehavior: _enterBehavior, readOnly = false, + onContentSizeChange, ...props }: ComposerEditorProps) { const inputRef = useRef(null); @@ -45,6 +46,7 @@ export function ComposerEditor({ editable={(props.editable ?? true) && !readOnly} selection={selection} onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} + onContentSizeChange={(event) => onContentSizeChange?.(event.nativeEvent.contentSize)} multiline={props.multiline ?? true} placeholderTextColorClassName={"accent-placeholder"} className="text-foreground" diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index 4e2ddd178b06..95726c9f8ba9 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -68,6 +68,14 @@ export interface ComposerEditorProps { readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; + /** + * Size of the laid-out text in dp, without insets. Width is the widest line, so a + * one-line draft reports its natural width; only fires when the size changes. + */ + readonly onContentSizeChange?: (size: { + readonly width: number; + readonly height: number; + }) => void; /** * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no * hardware Return handling. diff --git a/apps/mobile/src/native/composerEditorRevision.test.ts b/apps/mobile/src/native/composerEditorRevision.test.ts index 8fdacc944204..2ddd70e1c3d5 100644 --- a/apps/mobile/src/native/composerEditorRevision.test.ts +++ b/apps/mobile/src/native/composerEditorRevision.test.ts @@ -8,25 +8,6 @@ import { resolveComposerControlledEventCount, } from "./composerEditorRevision"; -describe("acknowledgeComposerNativeEvent", () => { - it("advances to newer native text revisions", () => { - expect(acknowledgeComposerNativeEvent(4, 5)).toBe(5); - }); - - it("accepts a duplicate event from the current native revision", () => { - expect(acknowledgeComposerNativeEvent(5, 5)).toBe(5); - }); - - it("rejects events older than the latest native text revision", () => { - expect(acknowledgeComposerNativeEvent(5, 4)).toBeNull(); - }); - - it("rejects malformed revision counters", () => { - expect(acknowledgeComposerNativeEvent(5, Number.NaN)).toBeNull(); - expect(acknowledgeComposerNativeEvent(5, 5.5)).toBeNull(); - }); -}); - describe("isComposerNativeEcho", () => { const snapshots = [{ eventCount: 3, value: "native", selection: { start: 6, end: 6 } }]; @@ -40,10 +21,6 @@ describe("isComposerNativeEcho", () => { expect(isComposerNativeEcho("parent edit", { start: 6, end: 6 }, 3, snapshots)).toBe(false); }); - it("matches value and revision when selection is uncontrolled", () => { - expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true); - }); - it("does not claim a controlled selection against an assumed state without one", () => { const assumed = [{ eventCount: 3, value: "native", selection: null }]; expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false); @@ -71,10 +48,6 @@ describe("resolveComposerControlledEventCount", () => { expect(resolveComposerControlledEventCount("", { start: 0, end: 0 }, 4, snapshots)).toBe(0); }); - it("acknowledges the latest native value at the latest revision", () => { - expect(resolveComposerControlledEventCount("ab", { start: 2, end: 2 }, 4, snapshots)).toBe(4); - }); - it("allows an unmatched parent-driven edit at the latest native revision", () => { expect(resolveComposerControlledEventCount("/plan ", { start: 6, end: 6 }, 4, snapshots)).toBe( 4, @@ -93,10 +66,6 @@ describe("resolveComposerControlledEventCount", () => { it("keeps a stale selection paired with current text behind the native revision", () => { expect(resolveComposerControlledEventCount("ab", { start: 1, end: 1 }, 4, snapshots)).toBe(3); }); - - it("does not control selection when no selection prop is provided", () => { - expect(resolveComposerControlledEventCount("ab", null, 4, snapshots)).toBe(4); - }); }); describe("pruneAcknowledgedComposerNativeEvents", () => { @@ -142,14 +111,6 @@ describe("pruneAcknowledgedComposerNativeEvents", () => { }); describe("assumeComposerControlledState", () => { - it("replaces the acknowledged history with the applied controlled state", () => { - const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }]; - - expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([ - { eventCount: 3, value: "", selection: null }, - ]); - }); - it("keeps native events that raced past the controlled revision", () => { const snapshots = [ { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }, diff --git a/apps/mobile/src/native/scrollEdgeEffects.test.ts b/apps/mobile/src/native/scrollEdgeEffects.test.ts deleted file mode 100644 index 4bf3afd037eb..000000000000 --- a/apps/mobile/src/native/scrollEdgeEffects.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { nativeTopScrollEdgeEffect } from "./scrollEdgeEffects"; - -describe("nativeTopScrollEdgeEffect", () => { - it("keeps UIKit content sampling instead of a local soft overlay on iOS 27", () => { - expect(nativeTopScrollEdgeEffect("ios", "27.0")).toBe("automatic"); - }); -}); diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 81ed0881c2f1..3e4a0f92b7cf 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -5,10 +5,18 @@ import type { MarkdownDocumentAsset, MarkdownStyle, } from "react-native-enriched-markdown"; +import type { ComposerContextRecord } from "@t3tools/contracts"; +import { contextChipPresentation } from "../lib/composerChipPresentation"; +import { CONTEXT_CHIP_ICON_SOURCES } from "../lib/contextChipIcons.generated"; +import { FONT_FAMILIES } from "../lib/fontFamilies"; +import { contextChipIconSource } from "../lib/contextChipIconSource"; import { markdownFileIconSource } from "../lib/markdownFileIcons"; import { markdownLinkIconSource } from "../lib/markdownLinkIcons"; import { resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation } from "../lib/markdownLinks"; -import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; +import type { + MarkdownLinkCustomization, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; import { enrichedContextLinkPresentation, enrichedLinkVariantPattern, @@ -16,27 +24,40 @@ import { enrichedSkillReference, } from "../lib/enrichedLinkPresentation"; +export function enrichedContextLinkCustomization( + href: string, + records?: ReadonlyArray, +): MarkdownLinkCustomization | undefined { + const context = enrichedContextLinkPresentation(href, records); + if (!context) return undefined; + return { + label: context.label, + icon: contextChipIconSource(context.icon), + chip: { accent: context.accent, tintsIcon: context.icon.kind === "chip" }, + }; +} + function presentation( url: string, props: Pick, -) { +): MarkdownLinkCustomization | undefined { const custom = props.linkCustomization?.(url); if (custom) return custom; - const context = enrichedContextLinkPresentation(url); - if (context) return { color: context.color, icon: markdownFileIconSource(context.icon) }; + const context = enrichedContextLinkCustomization(url); + if (context) return context; const skill = enrichedSkillReference(url, props.skills); if (skill) return { - color: props.textStyle.skillTextColor, label: enrichedSkillDisplayName(skill), - icon: markdownFileIconSource("mcp"), + icon: CONTEXT_CHIP_ICON_SOURCES.skill, + chip: { accent: contextChipPresentation("skill").accent, tintsIcon: true }, }; const link = resolveMarkdownLinkPresentation(url); if (link.kind === "file") return { - color: props.textStyle.fileTextColor, label: link.label, icon: markdownFileIconSource(link.icon), + chip: { accent: contextChipPresentation("mention").accent, tintsIcon: false }, }; const icon = link.kind === "external" ? resolveMarkdownLinkIcon(link.host) : null; return icon @@ -54,7 +75,7 @@ export function useEnrichedLinkVariants( ) { const { linkCustomization, skills, textStyle } = props; const links = useMemo(() => { - const byUrl = new Map>>(); + const byUrl = new Map(); for (const asset of assets) { if (asset.kind !== "link" || byUrl.has(asset.url)) continue; const value = presentation(asset.url, { linkCustomization, skills, textStyle }); @@ -62,11 +83,21 @@ export function useEnrichedLinkVariants( } return byUrl; }, [assets, linkCustomization, skills, textStyle]); + const chipForegrounds = useMemo( + () => + new Map( + [...links].map(([url, link]) => [ + url, + link.chip ? (link.chip.accent ?? textStyle.color) : undefined, + ]), + ), + [links, textStyle], + ); const linkVariants = useMemo( () => Object.fromEntries( [...links].map(([url, link]) => { - const color = link.color ?? textStyle.fileTextColor; + const chipForeground = chipForegrounds.get(url); return [ enrichedLinkVariantPattern(url), { @@ -77,15 +108,15 @@ export function useEnrichedLinkVariants( paddingHorizontal: 5, paddingVertical: 2, }, - fontFamily: textStyle.fontFamily, - color, + fontFamily: chipForeground ? FONT_FAMILIES.medium : textStyle.fontFamily, + color: chipForeground ?? link.color ?? textStyle.fileTextColor, underline: false, backgroundColor: "transparent", }, ]; }), ), - [links, textStyle], + [chipForegrounds, links, textStyle], ); const linkPillContent = useMemo>( () => @@ -95,11 +126,11 @@ export function useEnrichedLinkVariants( { label: link.label, iconUri: link.icon ? Image.resolveAssetSource(link.icon)?.uri : undefined, - iconTintColor: link.iconTintColor, + iconTintColor: link.chip?.tintsIcon ? chipForegrounds.get(url) : link.iconTintColor, }, ]), ), - [links], + [chipForegrounds, links], ); return { linkVariants, linkPillContent }; } diff --git a/apps/mobile/src/native/voiceTranscription.ios.test.ts b/apps/mobile/src/native/voiceTranscription.ios.test.ts index b08e32cdb6f6..d3ee19c262de 100644 --- a/apps/mobile/src/native/voiceTranscription.ios.test.ts +++ b/apps/mobile/src/native/voiceTranscription.ios.test.ts @@ -56,24 +56,6 @@ afterEach(() => { }); describe("getLocalVoiceTranscriber", () => { - it("keeps the selected language and Apple's resolved locale when the device language changes", async () => { - const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); - const deviceLocale = vi - .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") - .mockReturnValue({ ...resolvedOptions, locale: "sv-FI" }); - const transcriber = getLocalVoiceTranscriber()!; - const options = { signal: new AbortController().signal }; - - deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "de-DE" }); - const prepared = await transcriber.prepare(options); - deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "en-US" }); - - await expect(prepared.transcribe("file:///voice.m4a", options)).resolves.toBe("Hej världen."); - expect(mocks.prepare).toHaveBeenCalledWith("sv-FI"); - expect(prepared.locale).toBe("sv-SE"); - expect(mocks.transcribe).toHaveBeenCalledWith(audio, "sv-SE"); - }); - it("does not start native transcription after cancellation during a file read", async () => { const enteredRead = deferred(); const readResult = deferred(); @@ -97,44 +79,4 @@ describe("getLocalVoiceTranscriber", () => { expect(error).toMatchObject({ code: "cancelled" }); expect(mocks.transcribe).not.toHaveBeenCalled(); }); - - it.each(["prepare", "transcribe"] as const)( - "waits for native %s to finish before settling cancellation", - async (phase) => { - const enteredNative = deferred(); - const finishNative = deferred(); - if (phase === "prepare") { - mocks.prepare.mockImplementation(async () => { - enteredNative.resolve(); - await finishNative.promise; - return "sv-SE"; - }); - } else { - mocks.transcribe.mockImplementation(async () => { - enteredNative.resolve(); - await finishNative.promise; - return nativeTranscript; - }); - } - const controller = new AbortController(); - const options = { signal: controller.signal }; - const transcriber = getLocalVoiceTranscriber()!; - const operation = - phase === "prepare" - ? transcriber.prepare(options) - : (await transcriber.prepare(options)).transcribe("file:///voice.m4a", options); - const settled = vi.fn((value: unknown) => value); - const result = operation.then(settled, settled); - - await enteredNative.promise; - controller.abort(); - await new Promise((resolve) => setImmediate(resolve)); - expect(settled).not.toHaveBeenCalled(); - finishNative.resolve(); - - const error = await result; - expect(error).toBeInstanceOf(VoiceTranscriptionError); - expect(error).toMatchObject({ code: "cancelled" }); - }, - ); }); diff --git a/apps/mobile/src/native/workspace-stack-projection.test.ts b/apps/mobile/src/native/workspace-stack-projection.test.ts index e813c48faa6b..9ae5d9a4a791 100644 --- a/apps/mobile/src/native/workspace-stack-projection.test.ts +++ b/apps/mobile/src/native/workspace-stack-projection.test.ts @@ -3,12 +3,14 @@ import type { ParamListBase, StackNavigationState } from "@react-navigation/nati import { describe, expect, it } from "vite-plus/test"; import { + collapsedSplitTransition, nativeStackPopAction, nativeWorkspacePopAction, nativeWorkspacePopCount, projectWorkspaceStack, partitionStackPresentations, reconcileStackScreens, + type CollapsedSplitColumn, } from "./workspace-stack-projection"; const requireNavigation = NodeModule.createRequire( @@ -56,18 +58,6 @@ describe("workspace router projection", () => { }); }); - it("keeps the thread and file history in the detail column when a modal is opened", () => { - const state = history([home, thread, files, settings, legal]); - const projection = projectWorkspaceStack(state, (route) => route.name === "SettingsSheet"); - expect(projection).toEqual({ - primary: home, - detail: [thread, files], - overlays: [settings, legal], - }); - expect(projection.detail[0]).toBe(thread); - expect(state.routes).toEqual([home, thread, files, settings, legal]); - }); - it("retains a cold-linked detail route without changing the router history", () => { const state = history([thread, files]); expect(projectWorkspaceStack(state, () => false)).toEqual({ @@ -77,14 +67,6 @@ describe("workspace router projection", () => { }); expect(state.routes[0]).toBe(thread); }); - - it("restores the empty detail column after Back reaches the thread list", () => { - expect(projectWorkspaceStack(history([home]), () => false)).toEqual({ - primary: home, - detail: [], - overlays: [], - }); - }); }); describe("native workspace dismissal", () => { @@ -106,9 +88,6 @@ describe("native workspace dismissal", () => { expect(nativeWorkspacePopAction(state, settings.key)).toBeNull(); expect(nativeWorkspacePopAction(state, legal.key)).toBeNull(); }); - it("pops a native dismissed file while keeping the conversation and its draft mounted", () => { - expect(nativeWorkspacePopCount(history([home, thread, files]), files.key)).toBe(1); - }); it("removes the descendants when UIKit dismisses their parent conversation", () => { expect(nativeWorkspacePopCount(history([home, thread, files]), thread.key)).toBe(2); @@ -185,6 +164,111 @@ describe("native card pop handoff", () => { }); }); +describe("collapsed split Back", () => { + const routerOptions = { + routeNames: history([home]).routeNames, + routeParamList: {}, + routeGetIdList: {}, + }; + type Appearance = "willAppear" | "didAppear" | "willDisappear"; + // UIKit's appearance callbacks for one collapsed transition, against the router at each step. + function replay( + steps: ReadonlyArray]>, + ) { + let column: CollapsedSplitColumn = { visible: "secondary", leaving: null }; + let pop: ReturnType = null; + for (const [type, key, state] of steps) { + const transition = collapsedSplitTransition(column, { type, key }, state, home.key); + column = transition.column; + pop ??= transition.pop; + } + return { visible: column.visible, pop }; + } + + it("returns the router to the list so the next selection opens a new detail", () => { + const state = history([home, thread]); + const back = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["didAppear", home.key, state], + ]); + expect(back.visible).toBe("primary"); + const router = StackRouter({}); + const listed = router.getStateForAction(state, back.pop!, routerOptions); + expect(listed?.routes).toEqual([home]); + if (!listed || listed.stale !== false) throw new Error("Expected a rehydrated router history"); + const select = { + type: "NAVIGATE", + payload: { name: "Thread", params: { threadId: "next" } }, + } as const; + expect(router.getStateForAction(listed, select, routerOptions)?.routes[1]?.key).not.toBe( + thread.key, + ); + // Left on the hidden detail, the selection would only rename it and never show the column. + expect(router.getStateForAction(state, select, routerOptions)?.routes[1]?.key).toBe(thread.key); + }); + + it("clears every detail while keeping a presentation opened during the Back animation", () => { + const leaving = history([home, thread, files]); + const presented = history([home, thread, files, settings]); + const { pop } = replay([ + ["willDisappear", files.key, leaving], + ["didAppear", home.key, presented], + ]); + expect(StackRouter({}).getStateForAction(presented, pop!, routerOptions)?.routes).toEqual([ + home, + settings, + ]); + }); + + it("keeps the detail when it appears again before the list finishes appearing", () => { + const state = history([home, thread]); + const cancelledSwipe = replay([ + ["willDisappear", thread.key, state], + ["willAppear", home.key, state], + ["willAppear", thread.key, state], + ["didAppear", thread.key, state], + ]); + expect(cancelledSwipe).toEqual({ visible: "secondary", pop: null }); + expect( + replay([ + ["willDisappear", thread.key, state], + ["willAppear", thread.key, state], + ["didAppear", home.key, state], + ]).pop, + ).toBeNull(); + }); + + it("does not mistake JS navigation or a cold link for native Back", () => { + const pushed = history([home, thread, files]); + expect( + replay([ + ["willDisappear", thread.key, pushed], + ["didAppear", files.key, pushed], + ]), + ).toEqual({ visible: "secondary", pop: null }); + const presented = history([home, thread, settings]); + expect( + replay([ + ["willDisappear", thread.key, presented], + ["didAppear", home.key, presented], + ]).pop, + ).toBeNull(); + const popped = history([home]); + expect( + replay([ + ["willDisappear", thread.key, popped], + ["didAppear", home.key, popped], + ]).pop, + ).toBeNull(); + // A linked thread whose list appears first still needs its detail column shown. + expect(replay([["didAppear", home.key, history([home, thread])]])).toEqual({ + visible: "primary", + pop: null, + }); + }); +}); + describe("v5 stack handoff", () => { it("releases a completed native pop when the router acknowledges it", () => { const completed = new Set([files.key]); @@ -227,6 +311,7 @@ describe("v5 stack handoff", () => { settings, ]); }); + // regression: #17231 keep native screens ordered during stack pops it("retains a multi-page pop in order without changing the active router order", () => { const replacement = { ...thread, key: "replacement" }; const screens = reconcileStackScreens([home, thread, files], [home, replacement]); diff --git a/apps/mobile/src/native/workspace-stack-projection.ts b/apps/mobile/src/native/workspace-stack-projection.ts index 2d6afae419ee..71ebec0ed9c2 100644 --- a/apps/mobile/src/native/workspace-stack-projection.ts +++ b/apps/mobile/src/native/workspace-stack-projection.ts @@ -70,6 +70,53 @@ export function nativeStackPopAction( }; } +/** The column UIKit shows while a split is collapsed, and the active detail it began removing. */ +export interface CollapsedSplitColumn { + readonly visible: "primary" | "secondary"; + readonly leaving: string | null; +} + +/** + * A collapsed split nests the detail column's navigation controller in the list column's. Back + * from the detail root pops that controller whole, so no screen leaves its own stack or reports a + * dismissal: the list appearing after the active detail began leaving is the only sign of it. + */ +export function collapsedSplitTransition( + column: CollapsedSplitColumn, + event: { readonly type: "willAppear" | "didAppear" | "willDisappear"; readonly key: string }, + state: Pick, + primaryKey: string, +): { + readonly column: CollapsedSplitColumn; + readonly pop: ReturnType; +} { + switch (event.type) { + case "willDisappear": + // JS pushes, pops, and presentations have already moved the router past this screen. + return { + column: + state.index > 0 && state.routes[state.index]?.key === event.key + ? { ...column, leaving: event.key } + : column, + pop: null, + }; + case "willAppear": + // A cancelled swipe, or an expansion that keeps the detail on screen. + return { + column: column.leaving === event.key ? { ...column, leaving: null } : column, + pop: null, + }; + case "didAppear": + return { + column: { visible: event.key === primaryKey ? "primary" : "secondary", leaving: null }, + pop: + event.key === primaryKey && column.leaving + ? nativeStackPopAction(state, column.leaving, primaryKey) + : null, + }; + } +} + export function partitionStackPresentations( routes: readonly T[], isModal: (route: T) => boolean, diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts index 33b32955cf31..4e3b792653c8 100644 --- a/apps/mobile/src/persistence/mobile-database.test.ts +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -10,22 +10,6 @@ vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); import * as MobileDatabase from "./mobile-database"; describe("mobile database legacy cache migration", () => { - it.effect("keeps acquisition failures typed on database operations", () => - Effect.scoped( - Effect.gen(function* () { - openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable")); - - const database = yield* MobileDatabase.make; - const result = yield* Effect.result(database.loadPreferencesJson); - - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "MobileDatabaseError", operation: "open" }, - }); - }), - ), - ); - it("maps legacy thread records to their SQLite identity", () => { const payload = JSON.stringify({ schemaVersion: 2, @@ -43,23 +27,6 @@ describe("mobile database legacy cache migration", () => { }); }); - it("preserves the old shell payload for schema decoding after migration", () => { - const payload = JSON.stringify({ - schemaVersion: 1, - environmentId: "environment-1", - snapshotReceivedAt: "2026-07-01T00:00:00.000Z", - snapshot: {}, - }); - - expect(MobileDatabase.decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({ - environmentId: "environment-1", - kind: "shell", - cacheKey: "snapshot", - schemaVersion: 1, - payload, - }); - }); - it("skips malformed legacy records", () => { expect(MobileDatabase.decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull(); expect( @@ -120,36 +87,4 @@ describe("offline pull request cache", () => { }), ), ); - - it.effect( - "expires old records and bounds count and payload size without evicting other cache kinds", - () => - Effect.scoped( - Effect.gen(function* () { - const sqlite = yield* Effect.promise(sqliteDatabase); - const database = yield* MobileDatabase.make; - yield* database.saveCache(environmentId, "thread", "thread", 1, "thread"); - yield* database.saveCache(environmentId, "pull-request", "old", 1, "old"); - sqlite.prepare("UPDATE client_cache SET updated_at = 0 WHERE cache_key = 'old'").run(); - expect((yield* database.loadCache(environmentId, "pull-request", "old"))._tag).toBe( - "None", - ); - for (let i = 0; i < 260; i++) - yield* database.saveCache(environmentId, "pull-request", `page-${i}`, 1, "page"); - expect((yield* database.listCache("pull-request")).length).toBe(256); - const large = "x".repeat(4 * 1024 * 1024); - for (let i = 0; i < 10; i++) - yield* database.saveCache(environmentId, "pull-request", `diff-${i}`, 1, large); - const summary = yield* database.inspectCaches; - const prs = summary.find((row) => row.kind === "pull-request"); - expect(prs?.payloadBytes).toBeLessThanOrEqual(32 * 1024 * 1024); - expect(prs?.recordCount).toBeLessThanOrEqual(256); - expect((yield* database.loadCache(environmentId, "thread", "thread"))._tag).toBe("Some"); - yield* database.saveCache(environmentId, "pull-request", "oversized", 1, large + "x"); - expect((yield* database.loadCache(environmentId, "pull-request", "oversized"))._tag).toBe( - "None", - ); - }), - ), - ); }); diff --git a/apps/mobile/src/persistence/mobile-storage.test.ts b/apps/mobile/src/persistence/mobile-storage.test.ts index 9bccff93fa73..363875ade020 100644 --- a/apps/mobile/src/persistence/mobile-storage.test.ts +++ b/apps/mobile/src/persistence/mobile-storage.test.ts @@ -55,6 +55,7 @@ describe("mobile device identity", () => { }), ); + // regression: #18837 serialize initial device identity creation it.effect("reuses an existing device identity without generating or writing", () => Effect.gen(function* () { values.set(deviceIdKey, "existing-device"); diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 3415bd353bac..fc6c04aa5833 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -128,11 +128,6 @@ describe("React Compiler coverage", () => { expect(transformed("false")).not.toContain("react/compiler-runtime"); }); - it("app.config.ts turns the compiler on for Metro", () => { - const appConfig = NodeFS.readFileSync(NodePath.join(mobileRoot, "app.config.ts"), "utf8"); - expect(appConfig).toMatch(/experiments:\s*\{[^}]*reactCompiler:\s*true/); - }); - it.each(sourceFiles)("compiles %s", (file) => { const bailouts = compilerBailouts(file); if (file in KNOWN_BAILOUTS) { @@ -145,4 +140,4 @@ describe("React Compiler coverage", () => { it("lists only files that exist", () => { expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]); }); -}, 300_000); +}); diff --git a/apps/mobile/src/state/asset-url-state.test.ts b/apps/mobile/src/state/asset-url-state.test.ts index 3cb7ee81fdd9..136af9c41557 100644 --- a/apps/mobile/src/state/asset-url-state.test.ts +++ b/apps/mobile/src/state/asset-url-state.test.ts @@ -9,18 +9,6 @@ const SUCCESS = { }; describe("deriveAssetUrlState", () => { - it("passes a resolved URL through on a live environment", () => { - expect(deriveAssetUrlState({ connectionPhase: "connected", shared: SUCCESS })).toEqual(SUCCESS); - }); - - it("waits while the query is pending and the environment is still reachable", () => { - for (const connectionPhase of ["available", "connecting", "connected"] as const) { - expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ - _tag: "Loading", - }); - } - }); - it("stops waiting once the environment is offline, retrying, or in error", () => { for (const connectionPhase of ["offline", "reconnecting", "error"] as const) { expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Loading" } })).toEqual({ @@ -30,6 +18,7 @@ describe("deriveAssetUrlState", () => { } }); + // regression: #9123 show an error instead of an endless preview spinner it("reports disconnected when the query failed while the environment is down", () => { for (const connectionPhase of ["available", "offline", "reconnecting", "error"] as const) { expect(deriveAssetUrlState({ connectionPhase, shared: { _tag: "Failure" } })).toEqual({ @@ -47,10 +36,4 @@ describe("deriveAssetUrlState", () => { }); } }); - - it("reports a failed query on a connected environment", () => { - expect( - deriveAssetUrlState({ connectionPhase: "connected", shared: { _tag: "Failure" } }), - ).toEqual({ _tag: "Failure", reason: "failed" }); - }); }); diff --git a/apps/mobile/src/state/assets.test.ts b/apps/mobile/src/state/assets.test.ts index 3c4dd9251bc8..c1f8df94eb51 100644 --- a/apps/mobile/src/state/assets.test.ts +++ b/apps/mobile/src/state/assets.test.ts @@ -89,15 +89,6 @@ it("hides host assets with a denied grant while preserving attachments", () => { }); }); -it("stops waiting for an unresolved grant when the connection is offline", () => { - state.phase = "offline"; - expect(useAssetUrlState(environmentId, resource)).toEqual({ - _tag: "Failure", - reason: "disconnected", - }); - expect(state.assetQuery).not.toHaveBeenCalled(); -}); - it("lets the server authorize an explicit refresh before the client grant loads", async () => { await expect(useRefreshAssetUrl(environmentId, resource)()).resolves.toBe( "https://host.test/api/assets/image.png", diff --git a/apps/mobile/src/state/composer-attachment-uploads.test.ts b/apps/mobile/src/state/composer-attachment-uploads.test.ts index 226aeb95a7ea..3ebb0e490e0a 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.test.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.test.ts @@ -293,7 +293,7 @@ function uploadState() { ]; } -it.each(["decode", "read", "write"] as const)( +it.each(["decode", "write"] as const)( "keeps an accepted upload ready when the draft %s fails", async (fault) => { native.fault = fault; @@ -318,12 +318,10 @@ it.each(["decode", "read", "write"] as const)( }, ); -it.each(["http", "network", "permission"] as const)( +it.each(["http", "permission"] as const)( "keeps a real %s upload failure blocking Send", async (failure) => { if (failure === "http") native.transfer.mockResolvedValueOnce({ status: 503 }); - if (failure === "network") - native.transfer.mockRejectedValueOnce(new Error("Network unavailable")); if (failure === "permission") native.canOperate = false; const queue = worker(); await queue.settled(); diff --git a/apps/mobile/src/state/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts deleted file mode 100644 index 8494d626804c..000000000000 --- a/apps/mobile/src/state/legacy-plan-mode.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - resolvePendingTaskInteractionMode, - resolveProviderInteractionMode, -} from "./legacy-plan-mode"; - -describe("resolveProviderInteractionMode", () => { - it("clears saved plan mode when the provider cannot use T3 interaction modes", () => { - expect(resolveProviderInteractionMode({ showInteractionModeToggle: false }, "plan")).toBe( - "default", - ); - }); - - it("keeps supported choices and remains compatible with older server status", () => { - expect(resolveProviderInteractionMode({ showInteractionModeToggle: true }, "plan")).toBe( - "plan", - ); - expect(resolveProviderInteractionMode({}, "plan")).toBe("plan"); - expect(resolveProviderInteractionMode(null, "plan")).toBe("plan"); - expect(resolveProviderInteractionMode(undefined, undefined)).toBe("default"); - }); -}); - -describe("resolvePendingTaskInteractionMode", () => { - it.each([false, true])( - "clears a queued unsupported plan mode with preferenceLoaded=%s", - (preferenceLoaded) => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded, - planModeEnabled: true, - draftInteractionMode: "plan", - queuedInteractionMode: "plan", - provider: { showInteractionModeToggle: false }, - }), - ).toBe("default"); - }, - ); - - it("preserves a queued plan task while the preference is still loading", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: false, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: "plan", - }), - ).toBe("plan"); - }); - - it("forces build mode once the disabled preference has loaded", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: "plan", - }), - ).toBe("default"); - }); - - it("keeps a fresh draft in build mode while the preference is loading", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: false, - planModeEnabled: false, - draftInteractionMode: "plan", - queuedInteractionMode: undefined, - }), - ).toBe("default"); - }); - - it("honors the draft's mode when the plan preference is enabled", () => { - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: true, - draftInteractionMode: "plan", - queuedInteractionMode: undefined, - }), - ).toBe("plan"); - expect( - resolvePendingTaskInteractionMode({ - preferenceLoaded: true, - planModeEnabled: true, - draftInteractionMode: undefined, - queuedInteractionMode: "plan", - }), - ).toBe("default"); - }); -}); diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts deleted file mode 100644 index 0bb61ed2c429..000000000000 --- a/apps/mobile/src/state/pending-new-tasks-model.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; - -import type { QueuedThreadMessage } from "./thread-outbox-model"; -import type { ComposerDraft } from "./use-composer-drafts"; -import { buildPendingNewTasks } from "./pending-new-tasks-model"; - -const environmentId = EnvironmentId.make("env-1"); -const projectId = ProjectId.make("project-1"); - -function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { - return { - environmentId, - threadId: ThreadId.make(`thread-${id}`), - messageId: MessageId.make(id), - commandId: CommandId.make(`command-${id}`), - text: `queued ${id}`, - attachments: [], - createdAt, - creation: { - projectId, - workspaceMode: "local", - branch: "main", - worktreePath: null, - }, - }; -} - -function draft( - text: string, - createdAt: string, - overrides: Partial = {}, -): ComposerDraft { - return { - text, - attachments: [], - project: { environmentId, projectId, createdAt }, - ...overrides, - }; -} - -describe("buildPendingNewTasks", () => { - it("surfaces every new-task draft with content alongside queued creations", () => { - const tasks = buildPendingNewTasks({ - queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")], - drafts: { - "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", { - workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, - }), - "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"), - }, - }); - - expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ - ["draft", "second idea", null], - ["draft", "first idea", "main"], - ["pending", "queued a", "main"], - ]); - expect(tasks[1]).toMatchObject({ - key: "draft-task:new-task:draft-old", - environmentId, - projectId, - draftKey: "new-task:draft-old", - createdAt: "2026-09-05T09:00:00.000Z", - }); - }); - - it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => { - const tasks = buildPendingNewTasks({ - queuedMessages: [], - drafts: { - "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", { - modelSelection: { instanceId: "codex" as never, model: "gpt" }, - }), - "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"), - "new-task:unstamped": { text: "no project", attachments: [] }, - [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] }, - "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] }, - }, - }); - - expect(tasks).toEqual([]); - }); - - it("titles an attachment-only draft by its attachment count", () => { - const attachment = { - type: "image", - id: "image-1", - uri: "file:///image-1.png", - mimeType: "image/png", - name: "image-1.png", - width: 1, - height: 1, - sizeBytes: 1, - } as unknown as ComposerDraft["attachments"][number]; - const tasks = buildPendingNewTasks({ - queuedMessages: [], - drafts: { - "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", { - attachments: [attachment], - }), - }, - }); - - expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]); - }); - - it("orders queued creations newest first and skips existing-thread messages", () => { - const tasks = buildPendingNewTasks({ - queuedMessages: [ - queuedCreation("old", "2026-09-05T08:00:00.000Z"), - { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined }, - queuedCreation("new", "2026-09-05T10:00:00.000Z"), - ], - drafts: {}, - }); - - expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); - }); -}); diff --git a/apps/mobile/src/state/pending-task-editor-writes.test.ts b/apps/mobile/src/state/pending-task-editor-writes.test.ts index 9140f8dbf8a2..8cdfc006a815 100644 --- a/apps/mobile/src/state/pending-task-editor-writes.test.ts +++ b/apps/mobile/src/state/pending-task-editor-writes.test.ts @@ -178,53 +178,6 @@ describe("pending task editor writes", () => { expect(queuedMessageText(original.messageId)).toBe(secondEdit.text); }); - it("chains three rapid editor saves in order", async () => { - const original = queuedMessage("message-three-saves", "original"); - const firstEdit = queuedMessage("message-three-saves", "first edit"); - const secondEdit = queuedMessage("message-three-saves", "second edit"); - const thirdEdit = queuedMessage("message-three-saves", "third edit"); - const draftKey = "pending-task:message-three-saves"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(firstEdit.text)); - const firstWriteGate = blockNextWrite(); - const firstSave = flushPendingTaskEditorWrite({ - message: firstEdit, - baseline: capturePendingTaskEditorWriteBaseline(original.messageId), - draftKey, - }); - await firstWriteGate.started; - - const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(secondEdit.text)); - const secondWriteGate = blockNextWrite(); - const secondSave = flushPendingTaskEditorWrite({ - message: secondEdit, - baseline: secondBaseline, - draftKey, - }); - - const thirdBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(thirdEdit.text)); - const thirdWriteGate = blockNextWrite(); - const thirdSave = flushPendingTaskEditorWrite({ - message: thirdEdit, - baseline: thirdBaseline, - draftKey, - }); - - firstWriteGate.resolve(); - await expect(firstSave).resolves.toBe(false); - await secondWriteGate.started; - secondWriteGate.resolve(); - await expect(secondSave).resolves.toBe(false); - await thirdWriteGate.started; - thirdWriteGate.resolve(); - - await expect(thirdSave).resolves.toBe(true); - expect(queuedMessageText(original.messageId)).toBe(thirdEdit.text); - }); - it("keeps the handed-off revision when the middle editor write fails", async () => { const original = queuedMessage("message-middle-failure", "original"); const firstEdit = queuedMessage("message-middle-failure", "first edit"); @@ -294,40 +247,6 @@ describe("pending task editor writes", () => { expect(queuedMessageText(original.messageId)).toBe(unrelatedEdit.text); }); - it("lets a later editor retry after its predecessor write fails", async () => { - const original = queuedMessage("message-write-retry", "original"); - const failedEdit = queuedMessage("message-write-retry", "failed edit"); - const retryEdit = queuedMessage("message-write-retry", "retry edit"); - const draftKey = "pending-task:message-write-retry"; - await harness.manager.enqueue(original); - - setDraft(draftKey, draft(failedEdit.text)); - const failedWriteGate = blockNextWrite(); - const failedSave = flushPendingTaskEditorWrite({ - message: failedEdit, - baseline: capturePendingTaskEditorWriteBaseline(original.messageId), - draftKey, - }); - await failedWriteGate.started; - - const retryBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); - setDraft(draftKey, draft(retryEdit.text)); - const retryWriteGate = blockNextWrite(); - const retrySave = flushPendingTaskEditorWrite({ - message: retryEdit, - baseline: retryBaseline, - draftKey, - }); - - failedWriteGate.reject(new Error("disk full")); - await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" }); - await retryWriteGate.started; - retryWriteGate.resolve(); - - await expect(retrySave).resolves.toBe(true); - expect(queuedMessageText(original.messageId)).toBe(retryEdit.text); - }); - it("does not permit cleanup after a newer editor makes the draft unsendable", async () => { const original = queuedMessage("message-unsendable-draft", "original"); const editorEdit = queuedMessage("message-unsendable-draft", "saved edit"); diff --git a/apps/mobile/src/state/pending-thread-creation.test.ts b/apps/mobile/src/state/pending-thread-creation.test.ts index 235b4db430f2..67c217081073 100644 --- a/apps/mobile/src/state/pending-thread-creation.test.ts +++ b/apps/mobile/src/state/pending-thread-creation.test.ts @@ -1,6 +1,5 @@ import { CommandId, - ComposerContextId, EnvironmentId, MessageId, ProjectId, @@ -10,9 +9,6 @@ import { import { describe, expect, it } from "vite-plus/test"; import { - isPendingThreadCreationVisible, - pendingThreadCreationMessage, - pendingThreadCreationShell, resolvePendingThreadCreation, type PendingThreadCreation, } from "./pending-thread-creation"; @@ -108,17 +104,6 @@ describe("resolvePendingThreadCreation", () => { ).toBe(pending); }); - it.each(["error", "stopped", "interrupted"])("ends setup when startup is %s", (status) => { - expect( - resolvePendingThreadCreation({ - threadKey, - pending: null, - previous: pending, - detail: { messages: [prompt], latestTurn: null, session: { status } }, - }), - ).toBeNull(); - }); - it("preserves rejected task recovery", () => { const failed: PendingThreadCreation = { message: creation, @@ -153,101 +138,3 @@ describe("resolvePendingThreadCreation", () => { ).toBeNull(); }); }); - -describe("pendingThreadCreationShell", () => { - it("shapes a queued creation as the thread shell the screen renders before creation", () => { - expect(pendingThreadCreationShell(creation)).toMatchObject({ - environmentId: creation.environmentId, - id: creation.threadId, - projectId: creation.creation!.projectId, - title: "Fix the flaky login test", - modelSelection: creation.modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: "main", - worktreePath: null, - latestRun: null, - runtime: null, - latestUserMessageAt: creation.createdAt, - }); - }); - - it("keeps a local task's explicit worktree path", () => { - expect( - pendingThreadCreationShell({ - ...creation, - creation: { - ...creation.creation!, - workspaceMode: "local", - worktreePath: "/repo/.worktrees/feature", - }, - })?.worktreePath, - ).toBe("/repo/.worktrees/feature"); - }); - - it("returns null for a follow-up message or a creation without a model", () => { - expect(pendingThreadCreationShell({ ...creation, creation: undefined })).toBeNull(); - expect(pendingThreadCreationShell({ ...creation, modelSelection: undefined })).toBeNull(); - }); -}); - -describe("isPendingThreadCreationVisible", () => { - const creationMessageId = String(creation.messageId); - - it("stands in before any detail has loaded", () => { - expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: null })).toBe( - true, - ); - }); - - it("keeps standing in while the created thread has no messages yet", () => { - expect(isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: [] })).toBe(true); - }); - - it("keeps standing in when the thread holds only unrelated messages", () => { - expect( - isPendingThreadCreationVisible({ creationMessageId, loadedMessageIds: ["someone-else"] }), - ).toBe(true); - }); - - it("stands down once the delivered prompt lands under the same id", () => { - expect( - isPendingThreadCreationVisible({ - creationMessageId, - loadedMessageIds: ["someone-else", creationMessageId], - }), - ).toBe(false); - }); -}); - -describe("pendingThreadCreationMessage", () => { - it("keeps inline context available while the thread is being created", () => { - const record = { - version: 1 as const, - kind: "mention" as const, - contextId: ComposerContextId.make("setup-file"), - label: "Checkout.tsx", - path: "src/Checkout.tsx", - }; - const context = { version: 1 as const, records: [record] }; - const text = "[Checkout.tsx](t3-context://v1/mention/setup-file)"; - const message = pendingThreadCreationMessage({ ...creation, text, context }); - expect(message.text).toBe(text); - expect(message.context).toEqual(context); - }); - - it("renders the queued prompt as the first user message", () => { - expect(pendingThreadCreationMessage(creation)).toEqual({ - id: creation.messageId, - role: "user", - text: creation.text, - streaming: false, - createdAt: creation.createdAt, - updatedAt: creation.createdAt, - }); - }); - - it("omits the queued attachments rather than passing local draft ids to the feed", () => { - expect(pendingThreadCreationMessage(creation)).not.toHaveProperty("attachments"); - }); -}); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 852913943ac2..7d7885d37a80 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { vi } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; @@ -61,32 +60,6 @@ function makePreferencesState( } describe("mobile preferences state", () => { - it.effect("shares one preference load across consumers", () => - Effect.gen(function* () { - const load = vi.fn(() => - Promise.resolve({ baseFontSize: 17 }), - ); - const state = makePreferencesState({ - load: Effect.promise(load), - savePatch: (patch) => Effect.succeed(patch), - }); - const registry = AtomRegistry.make(); - const unmountFirst = registry.mount(state.preferencesAtom); - const unmountSecond = registry.mount(state.preferencesAtom); - - expect( - yield* AtomRegistry.getResult(registry, state.preferencesAtom, { - suspendOnWaiting: true, - }), - ).toEqual({ baseFontSize: 17 }); - expect(load).toHaveBeenCalledTimes(1); - - unmountSecond(); - unmountFirst(); - registry.dispose(); - }), - ); - it.effect("preserves an optimistic patch when the initial load finishes later", () => Effect.gen(function* () { const pendingLoad = deferred(); @@ -223,136 +196,4 @@ describe("mobile preferences state", () => { registry.dispose(); }), ); - - it.effect("does not roll back a newer optimistic write with the same value", () => - Effect.gen(function* () { - let saveCount = 0; - const state = makePreferencesState({ - load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }), - savePatch: (patch) => { - saveCount += 1; - return saveCount === 1 - ? Effect.fail( - new MobilePreferences.MobilePreferencesSaveError({ - cause: new Error("write failed"), - }), - ) - : Effect.succeed(patch); - }, - }); - const registry = AtomRegistry.make(); - const unmountPreferences = registry.mount(state.preferencesAtom); - const unmountUpdate = registry.mount(state.updatePreferencesAtom); - - yield* AtomRegistry.getResult(registry, state.preferencesAtom, { - suspendOnWaiting: true, - }); - registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); - registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 }); - - yield* Effect.promise(() => - vi.waitFor(() => { - expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); - expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( - { - baseFontSize: 18, - codeFontSize: 15, - }, - ); - }), - ); - - unmountUpdate(); - unmountPreferences(); - registry.dispose(); - }), - ); - - it.effect("rolls back an optimistic field when its save fails", () => - Effect.gen(function* () { - const state = makePreferencesState({ - load: Effect.succeed({ baseFontSize: 16 }), - savePatch: () => - Effect.fail( - new MobilePreferences.MobilePreferencesSaveError({ cause: new Error("write failed") }), - ), - }); - const registry = AtomRegistry.make(); - const unmountPreferences = registry.mount(state.preferencesAtom); - const unmountUpdate = registry.mount(state.updatePreferencesAtom); - - yield* AtomRegistry.getResult(registry, state.preferencesAtom, { - suspendOnWaiting: true, - }); - registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); - - yield* Effect.promise(() => - vi.waitFor(() => { - expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( - { - baseFontSize: 16, - }, - ); - }), - ); - - unmountUpdate(); - unmountPreferences(); - registry.dispose(); - }), - ); - - it.effect("rolls back to the last confirmed value after a later save fails", () => - Effect.gen(function* () { - let saveCount = 0; - const state = makePreferencesState({ - load: Effect.succeed({ baseFontSize: 16 }), - savePatch: () => { - saveCount += 1; - return saveCount === 1 - ? Effect.succeed({ baseFontSize: 14 }) - : Effect.fail( - new MobilePreferences.MobilePreferencesSaveError({ - cause: new Error("write failed"), - }), - ); - }, - }); - const registry = AtomRegistry.make(); - const unmountPreferences = registry.mount(state.preferencesAtom); - const unmountUpdate = registry.mount(state.updatePreferencesAtom); - - yield* AtomRegistry.getResult(registry, state.preferencesAtom, { - suspendOnWaiting: true, - }); - registry.set(state.updatePreferencesAtom, { baseFontSize: 14 }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(saveCount).toBe(1); - expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false); - expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( - { - baseFontSize: 14, - }, - ); - }), - ); - - registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(saveCount).toBe(2); - expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( - { - baseFontSize: 14, - }, - ); - }), - ); - - unmountUpdate(); - unmountPreferences(); - registry.dispose(); - }), - ); }); diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts deleted file mode 100644 index 6995ea463ad0..000000000000 --- a/apps/mobile/src/state/project-grouping.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - mobileProjectGroupingModePatch, - resolveMobileProjectGroupingSettings, -} from "./project-grouping.logic"; - -describe("mobile project grouping preferences", () => { - it("maps the legacy boolean while preferring the new mode", () => { - expect(resolveMobileProjectGroupingSettings({}).sidebarProjectGroupingMode).toBe("repository"); - expect( - resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false }) - .sidebarProjectGroupingMode, - ).toBe("separate"); - expect( - resolveMobileProjectGroupingSettings({ - projectGroupingEnabled: false, - projectGroupingMode: "repository_path", - }).sidebarProjectGroupingMode, - ).toBe("repository_path"); - }); - - it("dual-writes the legacy boolean for rollback compatibility", () => { - expect(mobileProjectGroupingModePatch("separate")).toEqual({ - projectGroupingMode: "separate", - projectGroupingEnabled: false, - }); - expect(mobileProjectGroupingModePatch("repository_path")).toEqual({ - projectGroupingMode: "repository_path", - projectGroupingEnabled: true, - }); - }); -}); diff --git a/apps/mobile/src/state/queries.filesystem.test.ts b/apps/mobile/src/state/queries.filesystem.test.ts index 73902d3cb8e7..8737caccf14b 100644 --- a/apps/mobile/src/state/queries.filesystem.test.ts +++ b/apps/mobile/src/state/queries.filesystem.test.ts @@ -74,27 +74,3 @@ it("keeps a file search pending until its grant loads, then shows matches", () = isPending: false, }); }); - -it("shows a confirmed denial and a connection failure separately", () => { - state.session = { authenticated: true, scopes: [] }; - expect(useComposerPathSearch(target)).toMatchObject({ - entries: [], - error: "This connection cannot search host files.", - isPending: false, - }); - state.session = null; - state.phase = "offline"; - expect(useComposerPathSearch(target)).toMatchObject({ - entries: [], - error: "This environment is not connected.", - isPending: false, - }); -}); - -it("leaves an inactive search idle while its grant loads", () => { - expect(useComposerPathSearch({ ...target, cwd: null, query: null })).toMatchObject({ - entries: [], - error: null, - isPending: false, - }); -}); diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts index 721012351a61..12f36dfe2e24 100644 --- a/apps/mobile/src/state/queries.test.ts +++ b/apps/mobile/src/state/queries.test.ts @@ -1,66 +1,5 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets"; - -describe("appQueries", () => { - it("normalizes composer path search input", () => { - expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app"); - expect(normalizeComposerPathSearchQuery(null)).toBe(""); - }); - - it("routes the first turn range through the full-thread diff query", () => { - const environmentId = EnvironmentId.make("environment-a"); - const threadId = ThreadId.make("thread-a"); - - expect( - buildCheckpointDiffTargets({ - environmentId, - threadId, - fromTurnCount: 0, - toTurnCount: 4, - ignoreWhitespace: true, - }), - ).toEqual({ - fullThread: { - environmentId, - input: { - threadId, - toTurnCount: 4, - ignoreWhitespace: true, - }, - }, - turn: null, - }); - }); - - it("routes later ranges through the incremental turn diff query", () => { - const environmentId = EnvironmentId.make("environment-a"); - const threadId = ThreadId.make("thread-a"); - - expect( - buildCheckpointDiffTargets({ - environmentId, - threadId, - fromTurnCount: 3, - toTurnCount: 4, - ignoreWhitespace: false, - }), - ).toEqual({ - fullThread: null, - turn: { - environmentId, - input: { - threadId, - fromTurnCount: 3, - toTurnCount: 4, - ignoreWhitespace: false, - }, - }, - }); - }); -}); +import { expect, it } from "@effect/vitest"; it("keeps an older exact PR in the mobile menu ahead of twenty newer substring matches", () => { const exact = { diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 67e456369b89..9309b0713553 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -8,8 +8,6 @@ import { type OrchestrationMessageContext, } from "@t3tools/contracts"; -import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; -import { isQueuedEditDraftKey, queuedEditDraftKey } from "./queued-edit-draft-key"; import { resolveQueuedEditPayload, type QueuedRunEdit } from "./queued-run-edit"; const image = (id: string): ChatAttachment => ({ @@ -45,16 +43,6 @@ function context(attachmentIds: ReadonlyArray): OrchestrationMessageCont }; } -describe("queued edit draft key", () => { - it("keeps the environment readable so attachment uploads still resolve it", () => { - const key = queuedEditDraftKey("env-1:thread-9", "run-3"); - - expect(isQueuedEditDraftKey(key)).toBe(true); - expect(isQueuedEditDraftKey("env-1:thread-9")).toBe(false); - expect(composerDraftEnvironmentId(key, [])).toBe("env-1"); - }); -}); - describe("resolveQueuedEditPayload", () => { it("resends the kept server attachments ahead of the newly uploaded ones", () => { const payload = resolveQueuedEditPayload({ @@ -91,16 +79,4 @@ describe("resolveQueuedEditPayload", () => { expect(payload.attachments.map((attachment) => attachment.id)).toEqual(["kept"]); expect(payload.context?.records).toEqual([expect.objectContaining({ attachmentId: "kept" })]); }); - - it("returns no context when every record lost its attachment", () => { - const payload = resolveQueuedEditPayload({ - edit: edit([]), - draftContext: context(["gone"]), - draftAttachments: [], - uploaded: [], - }); - - expect(payload.attachments).toEqual([]); - expect(payload.context).toBeUndefined(); - }); }); diff --git a/apps/mobile/src/state/remote-environment-projections.test.ts b/apps/mobile/src/state/remote-environment-projections.test.ts index 35e1b0b42abf..92fbd512e15b 100644 --- a/apps/mobile/src/state/remote-environment-projections.test.ts +++ b/apps/mobile/src/state/remote-environment-projections.test.ts @@ -128,34 +128,4 @@ describe("remote environment projections", () => { bearerToken: null, }); }); - - it("preserves saved identity across config-only updates and refreshes runtime state", () => { - const harness = makeHarness(); - const savedAtom = harness.projections.savedConnectionAtom(ENVIRONMENT_ID); - const runtimeAtom = harness.projections.runtimeStateAtom(ENVIRONMENT_ID); - const savedInitial = harness.registry.get(savedAtom); - const runtimeInitial = harness.registry.get(runtimeAtom); - const config = { cwd: "/repo" } as ServerConfig; - const initialPresentation = harness.registry.get(harness.presentationAtom(ENVIRONMENT_ID)); - - harness.registry.set( - harness.presentationAtom(ENVIRONMENT_ID), - initialPresentation === null ? null : { ...initialPresentation, serverConfig: config }, - ); - harness.registry.set(harness.serverConfigAtom(ENVIRONMENT_ID), config); - - expect(harness.registry.get(savedAtom)).toBe(savedInitial); - expect(harness.registry.get(runtimeAtom)).not.toBe(runtimeInitial); - expect(harness.registry.get(runtimeAtom)?.serverConfig).toBe(config); - }); - - it("keeps missing environments null", () => { - const harness = makeHarness(); - harness.registry.set(harness.presentationAtom(ENVIRONMENT_ID), null); - - expect( - harness.registry.get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)), - ).toBeNull(); - expect(harness.registry.get(harness.projections.runtimeStateAtom(ENVIRONMENT_ID))).toBeNull(); - }); }); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 820646c6d898..991fd72fdbad 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -1,4 +1,8 @@ import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { + createOutdatedServerDescriptionCommand, + createOutdatedServerUpdateCommand, +} from "@t3tools/client-runtime/state/outdatedServerUpdate"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; import { environmentCatalog } from "../connection/catalog"; @@ -11,6 +15,8 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt usageLimitSources: true, usageLimitsCommand: true, }); +export const describeOutdatedServer = createOutdatedServerDescriptionCommand(connectionAtomRuntime); +export const updateOutdatedServer = createOutdatedServerUpdateCommand(connectionAtomRuntime); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, diff --git a/apps/mobile/src/state/session.test.ts b/apps/mobile/src/state/session.test.ts index 8820d86afea0..c965f8d26bfe 100644 --- a/apps/mobile/src/state/session.test.ts +++ b/apps/mobile/src/state/session.test.ts @@ -1,5 +1,6 @@ import { AuthOrchestrationOperateScope, + AuthProvidersManageScope, EnvironmentId, type AuthSessionState, } from "@t3tools/contracts"; @@ -111,3 +112,25 @@ it("uses exact new-server permissions and keeps old-server grants usable", () => appAtomRegistry.set(source(primary), AsyncResult.success(session(true))); expect(useEnvironmentScope(primary, AuthOrchestrationOperateScope)).toBe(true); }); + +it("limits provider accounts to environments granting provider management", () => { + const environments = [{ environmentId: primary }, { environmentId: secondary }]; + const permitted = () => useEnvironmentsWithScope(environments, AuthProvidersManageScope); + appAtomRegistry.set( + source(primary), + AsyncResult.success({ ...session(true), permissions: [AuthOrchestrationOperateScope] }), + ); + expect(permitted()).toEqual(new Set()); + appAtomRegistry.set( + source(secondary), + AsyncResult.success({ ...session(false), permissions: [AuthProvidersManageScope] }), + ); + expect(permitted()).toEqual(new Set([secondary])); + appAtomRegistry.set(source(primary), AsyncResult.success(session(true))); + expect(permitted()).toEqual(new Set([primary, secondary])); + appAtomRegistry.set( + source(secondary), + AsyncResult.success({ ...session(true), scopes: [AuthProvidersManageScope], permissions: [] }), + ); + expect(permitted()).toEqual(new Set([primary])); +}); diff --git a/apps/mobile/src/state/thread-composer-error.test.ts b/apps/mobile/src/state/thread-composer-error.test.ts index 7c3ef653ebba..3e91b7cb778c 100644 --- a/apps/mobile/src/state/thread-composer-error.test.ts +++ b/apps/mobile/src/state/thread-composer-error.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; import { appAtomRegistry } from "./atom-registry"; import { - clearThreadComposerError, clearThreadComposerErrorsForEnvironment, setThreadComposerError, threadComposerErrorsAtom, @@ -13,18 +12,6 @@ afterEach(() => { }); describe("thread composer errors", () => { - it("clears a message-scoped error only for that message", () => { - setThreadComposerError("environment-1:thread-1", "rejected", "message-1"); - - clearThreadComposerError("environment-1:thread-1", "message-2"); - expect(appAtomRegistry.get(threadComposerErrorsAtom)["environment-1:thread-1"]?.message).toBe( - "rejected", - ); - - clearThreadComposerError("environment-1:thread-1"); - expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); - }); - it("clears every thread's error for a removed environment and no others", () => { setThreadComposerError("environment-1:thread-1", "one"); setThreadComposerError("environment-1:thread-2", "two"); diff --git a/apps/mobile/src/state/thread-list-environments.test.ts b/apps/mobile/src/state/thread-list-environments.test.ts deleted file mode 100644 index 7abf9d6b50ce..000000000000 --- a/apps/mobile/src/state/thread-list-environments.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - EnvironmentId, - ProviderInstanceId, - ProviderDriverKind, - type ServerConfig, - type ServerProvider, -} from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; -import { Atom, AtomRegistry } from "effect/reactivity"; -import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; - -const ID = EnvironmentId.make("one"); -const OTHER_ID = EnvironmentId.make("two"); -const provider: ServerProvider = { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-09-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], -}; -const config = { - providers: [provider], - environment: { capabilities: {}, platform: { machine: "laptop" } }, - settings: { threadGroups: {} }, -} as unknown as ServerConfig; - -function harness() { - const registry = AtomRegistry.make(); - const configs = Atom.make>( - new Map([ - [ID, config], - [OTHER_ID, config], - ]), - ); - const list = createThreadListEnvironmentsAtom(configs); - let sourceNotifications = 0; - let listNotifications = 0; - registry.subscribe(configs, () => sourceNotifications++); - registry.subscribe(list, () => listNotifications++); - registry.get(list); - sourceNotifications = 0; - listNotifications = 0; - return { - registry, - configs, - list, - read: () => registry.get(list), - write: (next: ServerConfig) => - registry.set( - configs, - new Map([ - [ID, next], - [OTHER_ID, config], - ]), - ), - notifications: () => ({ source: sourceNotifications, list: listNotifications }), - }; -} - -describe("thread list environment projection", () => { - it("keeps navigation stable while freshness, catalog and workspace updates reach full-config consumers", () => { - const h = harness(); - try { - const initial = h.read(); - const changed: ServerConfig = { - ...config, - providers: [ - { - ...provider, - checkedAt: "2026-09-02T00:00:00.000Z", - usageLimits: { checkedAt: "2026-09-02T00:00:00.000Z", windows: [] }, - models: [{ slug: "new-model", name: "New model", isCustom: false, capabilities: null }], - workspaceSnapshots: [ - { - cwd: "/repo", - checkedAt: "2026-09-02T00:00:00.000Z", - slashCommands: [], - skills: [], - }, - ], - }, - ], - }; - h.write(changed); - expect(h.read()).toBe(initial); - expect(h.registry.get(h.configs).get(ID)?.providers[0]?.usageLimits?.checkedAt).toBe( - "2026-09-02T00:00:00.000Z", - ); - expect(h.notifications()).toEqual({ source: 1, list: 0 }); - } finally { - h.registry.dispose(); - } - }); - - it.each([ - ["threadSettlement", "settlementEnvironmentIds"], - ["threadSnooze", "snoozeEnvironmentIds"], - ["threadPinning", "pinningEnvironmentIds"], - ["threadPinReorder", "pinReorderEnvironmentIds"], - ["threadActiveReorder", "activeReorderEnvironmentIds"], - ["threadTitleRegeneration", "titleRegenerationEnvironmentIds"], - ] as const)("tracks enabling and removing %s", (capability, collection) => { - const h = harness(); - try { - h.write({ - ...config, - environment: { - ...config.environment, - capabilities: { ...config.environment.capabilities, [capability]: true }, - }, - }); - expect([...h.read()[collection]]).toEqual([ID]); - h.write({ - ...config, - environment: { - ...config.environment, - capabilities: { ...config.environment.capabilities, [capability]: false }, - }, - }); - expect(h.read()[collection].size).toBe(0); - const disabled = h.read(); - h.write(config); - expect(h.read()).toBe(disabled); - expect(h.notifications().list).toBe(2); - } finally { - h.registry.dispose(); - } - }); - - it.each([ - ["instanceId", ProviderInstanceId.make("other")], - ["driver", ProviderDriverKind.make("claudeAgent")], - ["displayName", "Work account"], - ["accentColor", "#123456"], - ["iconUrl", "https://example.test/icon.png"], - ] as const)( - "updates and restores provider %s without changing another environment's providers", - (key, value) => { - const h = harness(); - try { - const otherProviders = h.read().providersByEnvironmentId.get(OTHER_ID); - h.write({ ...config, providers: [{ ...provider, [key]: value }] }); - expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(value); - expect(h.read().providersByEnvironmentId.get(OTHER_ID)).toBe(otherProviders); - h.write(config); - expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(provider[key]); - expect(h.notifications().list).toBe(2); - } finally { - h.registry.dispose(); - } - }, - ); - - it("tracks provider membership and order in both directions", () => { - const h = harness(); - try { - const second = { ...provider, instanceId: ProviderInstanceId.make("work") }; - h.write({ ...config, providers: [provider, second] }); - expect( - h - .read() - .providersByEnvironmentId.get(ID) - ?.map((p) => p.instanceId), - ).toEqual(["codex", "work"]); - h.write({ ...config, providers: [second, provider] }); - expect( - h - .read() - .providersByEnvironmentId.get(ID) - ?.map((p) => p.instanceId), - ).toEqual(["work", "codex"]); - h.write({ ...config, providers: [] }); - expect(h.read().providersByEnvironmentId.get(ID)).toEqual([]); - h.write(config); - expect( - h - .read() - .providersByEnvironmentId.get(ID) - ?.map((p) => p.instanceId), - ).toEqual(["codex"]); - expect(h.notifications().list).toBe(4); - } finally { - h.registry.dispose(); - } - }); - - it("restores detected machine kind after removing an override", () => { - const h = harness(); - try { - expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop"); - h.write({ ...config, settings: { ...config.settings, environmentIcon: "server" } }); - expect(h.read().machineByEnvironmentId.get(ID)).toBe("server"); - h.write(config); - expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop"); - const detected = h.read(); - h.write({ ...config, settings: { ...config.settings, environmentIcon: "laptop" } }); - expect(h.read()).toBe(detected); - } finally { - h.registry.dispose(); - } - }); - - it("removes forgotten environments and repopulates them after reconnect", () => { - const h = harness(); - try { - h.registry.set(h.configs, new Map()); - expect(h.read().providersByEnvironmentId.size).toBe(0); - expect(h.read().machineByEnvironmentId.size).toBe(0); - h.write(config); - expect([...h.read().providersByEnvironmentId.keys()]).toEqual([ID, OTHER_ID]); - expect(h.notifications().list).toBe(2); - } finally { - h.registry.dispose(); - } - }); -}); diff --git a/apps/mobile/src/state/thread-outbox-removal.test.ts b/apps/mobile/src/state/thread-outbox-removal.test.ts index 97a7331ff7e0..9a0bd840f58a 100644 --- a/apps/mobile/src/state/thread-outbox-removal.test.ts +++ b/apps/mobile/src/state/thread-outbox-removal.test.ts @@ -129,60 +129,6 @@ describe("thread outbox removal", () => { expect(remaining).toEqual([edited]); }); - it("clears a removed pending task draft and includes its editor-only files", async () => { - const message = queuedMessage({ - environmentId: "environment-1", - messageId: "message-pending", - fileUri: "file:///documents/t3-composer-attachments/queued.pdf", - creation: true, - }); - const editorOnlyFile = { - id: "file-editor-only", - type: "file" as const, - name: "editor-only.pdf", - mimeType: "application/pdf", - sizeBytes: 84, - fileUri: "file:///documents/t3-composer-attachments/editor-only.pdf", - }; - const draftKey = `pending-task:${message.messageId}`; - appAtomRegistry.set(composerDraftsAtom, { - [draftKey]: { text: "edited", attachments: [editorOnlyFile] }, - }); - await harness.manager.enqueue(message); - - await expect(removeThreadOutboxMessage(message)).resolves.toBe(true); - - expect(harness.waitForDrafts).toHaveBeenCalledOnce(); - expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(draftKey, { - deferAttachmentCleanup: true, - }); - expect(harness.flushDrafts).toHaveBeenCalledOnce(); - expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([ - ...message.attachments, - editorOnlyFile, - ]); - expect(harness.flushDrafts.mock.invocationCallOrder[0]).toBeLessThan( - harness.cleanup.mock.invocationCallOrder[0]!, - ); - }); - - it("does not flush composer drafts when a removed creation has no editor draft", async () => { - const message = queuedMessage({ - environmentId: "environment-1", - messageId: "message-without-editor-draft", - fileUri: "file:///documents/t3-composer-attachments/queued.pdf", - creation: true, - }); - await harness.manager.enqueue(message); - - await expect(removeThreadOutboxMessage(message)).resolves.toBe(true); - - expect(harness.waitForDrafts).toHaveBeenCalledOnce(); - expect(harness.clearDraft).not.toHaveBeenCalled(); - expect(harness.flushDrafts).not.toHaveBeenCalled(); - expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments); - }); - it("releases only the cleared environment's queued attachment files", async () => { const cleared = queuedMessage({ environmentId: "environment-1", diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index e36edf276baa..0d08c2af1073 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -78,15 +78,12 @@ vi.mock("expo-file-system", () => { import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, - groupQueuedThreadMessages, isQueuedThreadCreationSendable, - modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, - threadOutboxRetryDelayMs, type QueuedThreadMessage, } from "./thread-outbox-model"; import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; @@ -140,15 +137,6 @@ describe("thread outbox", () => { decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), ).toEqual(message); }); - it("retains queue mode when a queued provider switch reloads from storage", () => { - const message: QueuedThreadMessage = { - ...queuedMessage({ messageId: "queued-switch", createdAt: "2026-09-17T09:00:00.000Z" }), - dispatchMode: "queue", - }; - expect( - decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))), - ).toEqual(message); - }); it.each(["read", "json", "schema"] as const)( "recovers usable messages without permitting cleanup after a record %s failure", async (failure) => { @@ -303,64 +291,6 @@ describe("thread outbox", () => { expect(load).toHaveBeenCalledTimes(2); }); - it("groups messages by scoped thread and preserves creation order", () => { - const later = queuedMessage({ - messageId: "message-2", - createdAt: "2026-06-08T10:00:02.000Z", - }); - const earlier = queuedMessage({ - messageId: "message-1", - createdAt: "2026-06-08T10:00:01.000Z", - }); - - expect(groupQueuedThreadMessages([later, earlier])).toEqual({ - "environment-1:thread-1": [earlier, later], - }); - }); - - it("decodes the persisted schema and rejects incomplete messages", () => { - const message = queuedMessage({ - messageId: "message-1", - createdAt: "2026-06-08T10:00:01.000Z", - }); - - expect( - decodeQueuedThreadMessage({ - schemaVersion: 1, - ...message, - }), - ).toEqual(message); - expect(() => - decodeQueuedThreadMessage({ - schemaVersion: 1, - environmentId: "environment-1", - }), - ).toThrow(); - }); - - it("persists generic attachment paths without embedding their contents", () => { - const message = { - ...queuedMessage({ - messageId: "message-file", - createdAt: "2026-06-08T10:00:01.000Z", - }), - attachments: [ - { - id: "file-1", - type: "file" as const, - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - fileUri: "file:///documents/report.pdf", - uploadedAttachmentId: "pending-report-pdf", - uploadEnvironmentId: EnvironmentId.make("environment-1"), - }, - ], - } satisfies QueuedThreadMessage; - - expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); - }); - it("reads file-backed images from v4 queued messages", () => { const message = { ...queuedMessage({ @@ -417,22 +347,6 @@ describe("thread outbox", () => { }); }); - it("compares model options as part of the queued settings change", () => { - const base = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "medium" }], - } as const; - - expect(modelSelectionsEqual(base, base)).toBe(true); - expect( - modelSelectionsEqual(base, { - ...base, - options: [{ id: "reasoningEffort", value: "xhigh" }], - }), - ).toBe(false); - }); - it("normalizes queued plan mode against the queued provider, not the current thread", () => { const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }; const antigravity = { @@ -475,59 +389,6 @@ describe("thread outbox", () => { ).toBe("plan"); }); - it("normalizes a legacy queued message that inherits unsupported plan mode", () => { - const modelSelection = { - instanceId: ProviderInstanceId.make("google-personal"), - model: "gemini-test-thinking", - }; - expect( - resolveQueuedThreadSettings( - queuedMessage({ messageId: "legacy-plan", createdAt: "2026-09-02T10:00:00.000Z" }), - { modelSelection, runtimeMode: "approval-required", interactionMode: "plan" }, - [{ instanceId: modelSelection.instanceId, showInteractionModeToggle: false }], - ).interactionMode, - ).toBe("default"); - }); - - it("backs off queued delivery retries and caps them at sixteen seconds", () => { - expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ - 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, - ]); - }); - - it("serializes mutations even when an earlier mutation is slower", async () => { - const registry = AtomRegistry.make(); - const manager = createThreadOutboxManager({ - registry, - storage: { - load: async () => ({ messages: [], errors: [] }), - write: async () => undefined, - remove: async () => undefined, - }, - }); - const order: string[] = []; - let releaseFirst!: () => void; - const firstBlocked = new Promise((resolve) => { - releaseFirst = resolve; - }); - - const first = manager.serialize(async () => { - order.push("first:start"); - await firstBlocked; - order.push("first:end"); - }); - const second = manager.serialize(async () => { - order.push("second"); - }); - - await Promise.resolve(); - expect(order).toEqual(["first:start"]); - releaseFirst(); - await Promise.all([first, second]); - expect(order).toEqual(["first:start", "first:end", "second"]); - registry.dispose(); - }); - it("holds the mutation queue while persisted messages are loading", async () => { const registry = AtomRegistry.make(); const message = queuedMessage({ @@ -786,31 +647,6 @@ describe("thread outbox", () => { registry.dispose(); }); - it("replaces an existing message when an enqueue retry uses the same id", async () => { - const registry = AtomRegistry.make(); - const manager = createThreadOutboxManager({ - registry, - storage: { - load: async () => ({ messages: [], errors: [] }), - write: async () => undefined, - remove: async () => undefined, - }, - }); - const message = queuedMessage({ - messageId: "message-1", - createdAt: "2026-06-08T10:00:01.000Z", - }); - const retried = { ...message, text: "retried" }; - - await manager.enqueue(message); - await manager.enqueue(retried); - - expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ - "environment-1:thread-1": [retried], - }); - registry.dispose(); - }); - it("updates a queued message in place but never resurrects a removed one", async () => { const registry = AtomRegistry.make(); const stored = new Map(); @@ -1258,23 +1094,6 @@ describe("thread outbox", () => { ).toEqual({ step: "send" }); }); - it("waits for provider capabilities before sending text-only queued messages", () => { - expect( - resolveThreadOutboxDispatchStep({ - deliveryAction: "send", - fileAttachments: [], - serverConfig: null, - }), - ).toEqual({ step: "retry" }); - expect( - resolveThreadOutboxDispatchStep({ - deliveryAction: "send", - fileAttachments: [], - serverConfig: { maxFileUploadBytes: undefined }, - }), - ).toEqual({ step: "send" }); - }); - it("only removes a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ diff --git a/apps/mobile/src/state/thread-provider-switching.test.ts b/apps/mobile/src/state/thread-provider-switching.test.ts index 950a4dd33685..797a86882501 100644 --- a/apps/mobile/src/state/thread-provider-switching.test.ts +++ b/apps/mobile/src/state/thread-provider-switching.test.ts @@ -9,12 +9,6 @@ const startedThread = { runtime: { status: "idle" }, } as never; -const unstartedThread = { - latestRun: null, - latestUserMessageAt: null, - runtime: null, -} as never; - function projectionWithSession(supportsProviderSwitchingViaHandoff: boolean) { return { thread: { id: "thread", activeProviderThreadId: "provider-thread" }, @@ -33,15 +27,6 @@ function projectionWithSession(supportsProviderSwitchingViaHandoff: boolean) { } describe("threadAllowsProviderSwitch", () => { - it("offers every provider when the session can hand the conversation off", () => { - expect( - threadAllowsProviderSwitch({ - thread: startedThread, - projection: projectionWithSession(true), - }), - ).toBe(true); - }); - it("keeps a started thread on its provider when the session cannot hand off", () => { expect( threadAllowsProviderSwitch({ @@ -51,10 +36,6 @@ describe("threadAllowsProviderSwitch", () => { ).toBe(false); }); - it("leaves a thread that never ran a turn unbound", () => { - expect(threadAllowsProviderSwitch({ thread: unstartedThread, projection: null })).toBe(true); - }); - it("allows an imported thread to hand off before opening a provider session", () => { expect( threadAllowsProviderSwitch({ diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index a19d5d020c52..77acad45521e 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -109,7 +109,10 @@ vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => comman vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); return { - environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) }, + environmentThreadDetails: { + queueWorkflowAtom: () => Atom.make(null), + checkpointSummariesAtom: () => Atom.make([]), + }, threadEnvironment: { respondToApproval: state.approve, respondToUserInput: state.answer, @@ -230,17 +233,6 @@ describe("mobile task permissions", () => { ); }); - it("preserves offline queueing without dispatching feedback through the queue action", async () => { - state.connectionState = "disconnected"; - expect(await useThreadComposerState().onSendMessage()).toBe("message"); - expect(state.enqueue).toHaveBeenCalledTimes(1); - state.draft.text = "/feedback The agent stopped early."; - expect(await useThreadComposerState().onSendMessage()).toBeNull(); - expect(state.feedback).not.toHaveBeenCalled(); - expect(state.enqueue).toHaveBeenCalledTimes(1); - expect(state.clearDraft).toHaveBeenCalledTimes(1); - }); - it("rechecks the target environment before an approval response", async () => { state.grantedEnvironments.add("secondary"); const requests = useSelectedThreadRequests(); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index ce99a6a7c259..94dc617a50d4 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -191,7 +191,6 @@ import { ensureComposerDraftsLoaded, type ComposerDraft, findNewTaskDraftKeys, - findLocalComposerClipboardAttachment, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, @@ -212,7 +211,6 @@ import { waitForComposerDraftsLoaded, setStickyComposerModelSelection, stickyComposerModelSelectionAtom, - undoComposerDraftMerge, undoComposerDraftMergeState, updateComposerDraftSettings, } from "./use-composer-drafts"; @@ -354,22 +352,6 @@ function modelReconciliationHarness( } describe("mobile composer drafts", () => { - it("restores a PR checkout source after restarting the composer", () => { - const draft: ComposerDraft = { - text: "Address feedback", - attachments: [], - workspaceSelection: { - mode: "worktree", - branch: "fork/feature", - worktreePath: null, - startFromOrigin: false, - pullRequestReference: "https://github.com/acme/repo/pull/123", - }, - }; - expect( - decodePersistedComposerState({ schemaVersion: 1, drafts: { task: draft } }).drafts.task, - ).toEqual(draft); - }); it.each([false, true])( "restores visible file chips from legacy drafts (archived: %s)", async (archived) => { @@ -491,54 +473,6 @@ describe("mobile composer drafts", () => { }, ); - it("keeps a long attachment filename and a bounded chip label through reload", () => { - const key = "environment:long-file"; - const file = { - type: "file" as const, - id: "long-file", - name: `${"a".repeat(210)}.txt`, - mimeType: "text/plain", - sizeBytes: 1, - fileUri: "file:///long-file.txt", - }; - appendComposerDraftAttachments(key, [file], { appendReference: true }); - const draft = getComposerDraftSnapshot(key); - const reloaded = decodePersistedComposerState( - JSON.parse( - JSON.stringify({ - schemaVersion: 1, - drafts: { [key]: draft }, - }), - ), - ).drafts[key]; - expect(reloaded?.context?.records[0]).toMatchObject({ name: file.name, attachmentId: file.id }); - expect(reloaded?.context?.records[0]?.label.length).toBeLessThanOrEqual(200); - }); - - it("gives a folded paste a chip that survives the send", () => { - const key = "environment-1:thread-1"; - - const pasted = { - type: "file" as const, - id: "pasted-1", - name: "pasted-text.txt", - mimeType: "text/plain", - sizeBytes: 40_000, - fileUri: "file:///pasted-text.txt", - }; - appendComposerDraftAttachments(key, [pasted], { appendReference: true }); - - const draft = getComposerDraftSnapshot(key); - - expect(draft.text).toContain("pasted-text.txt"); - expect(draft.context?.records).toMatchObject([ - { kind: "file", attachmentId: pasted.id, name: pasted.name }, - ]); - - const [record] = draft.context?.records ?? []; - expect(draft.text).toContain(String(record?.contextId)); - }); - it("retains a pick matching cached state until the live shell confirms it", () => { const environmentId = EnvironmentId.make("environment-1"); const threadId = ThreadId.make("thread-1"); @@ -681,49 +615,6 @@ describe("mobile composer drafts", () => { ]); }); - it.each(["new-task:draft-1", "pending-task:queued-1"])( - "finds draft-only local clipboard files in %s", - (key) => { - const environmentId = EnvironmentId.make("environment-1"); - const file = { - type: "file" as const, - id: "local-file", - name: "notes.txt", - mimeType: "text/plain", - sizeBytes: 4, - fileUri: "file:///notes.txt", - }; - appAtomRegistry.set(composerDraftsAtom, { - [key]: { - text: "", - attachments: [file], - project: { - environmentId, - projectId: ProjectId.make("project-1"), - createdAt: "2026-01-01T00:00:00.000Z", - }, - }, - }); - appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { - queued: [ - { - environmentId, - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("queued-1"), - commandId: CommandId.make("command-1"), - text: "Queued", - attachments: [], - createdAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - expect(findLocalComposerClipboardAttachment(environmentId, file.id)).toEqual(file); - expect( - findLocalComposerClipboardAttachment(EnvironmentId.make("different-environment"), file.id), - ).toBeUndefined(); - }, - ); - it("keeps over-limit recovery context reloadable without losing other drafts", () => { const key = "environment-1:recovery"; const merged = mergeComposerDraftContentState( @@ -1088,48 +979,6 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey).context).toBeUndefined(); }); - // Hydration is one-shot per module instance and the attachment sweep now - // triggers it too, so this test must observe it before any sweep test runs. - it("hydrates generic file attachments from their saved local paths", () => { - const file = { - id: "file-1", - type: "file" as const, - name: "report.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - fileUri: "file:///documents/report.pdf", - }; - - expect( - decodePersistedComposerState({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": { text: "Review this file", attachments: [file] }, - }, - }).drafts, - ).toEqual({ - "environment-1:thread-1": { - text: "Review this file [report.pdf](t3-context://v1/file/file-1) ", - attachments: [file], - context: { - version: 1, - records: [ - { - version: 1, - contextId: file.id, - kind: "file", - label: file.name, - attachmentId: file.id, - name: file.name, - mimeType: file.mimeType, - sizeBytes: file.sizeBytes, - }, - ], - }, - }, - }); - }); - it("releases videos rejected by the live draft limit and keeps accepted files", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); @@ -1361,29 +1210,6 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); }); - it("cleans up an unreferenced image upload even when there is no local file URI", async () => { - const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => outboxLoad.mockRestore()); - const environmentId = EnvironmentId.make("environment-1"); - await releaseUnusedComposerAttachmentFiles([ - { - id: "photo", - type: "image", - name: "photo.png", - mimeType: "image/png", - sizeBytes: 3, - dataUrl: "data:image/png;base64,YWJj", - previewUri: "file:///photo.png", - uploadedAttachmentId: "pending-photo", - uploadEnvironmentId: environmentId, - }, - ]); - expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ - "pending-photo", - ]); - expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); - }); - it.each(["file", "image"] as const)( "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account", async (type) => { @@ -1876,29 +1702,6 @@ describe("mobile composer drafts", () => { } }); - it("rejects persisted images without image bytes or a file URI", () => { - expect(() => - decodePersistedComposerState({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": { - text: "Saved image", - attachments: [ - { - id: "image-1", - type: "image", - name: "photo.png", - mimeType: "image/png", - sizeBytes: 3, - previewUri: "file:///photo.png", - }, - ], - }, - }, - }), - ).toThrow(); - }); - it("hydrates selector state even when the message content is empty", () => { const hydrated = Object.entries( decodePersistedComposerState({ @@ -1949,30 +1752,6 @@ describe("mobile composer drafts", () => { }, }); }); - it("keeps legacy content-only drafts and rejects invalid selector state", () => { - expect( - decodePersistedComposerState({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": DRAFT, - }, - }).drafts, - ).toEqual({ - "environment-1:thread-1": DRAFT, - }); - - expect(() => - decodePersistedComposerState({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": { - ...DRAFT, - runtimeMode: "sometimes-safe", - }, - }, - }), - ).toThrow(); - }); it("keeps share-import receipts on otherwise contentless new-task drafts", () => { const receiptDraft: ComposerDraft = { @@ -2014,27 +1793,6 @@ describe("mobile composer drafts", () => { expect(kept[0]).toMatchObject(receiptDraft); }); - it("migrates archived signed-out new-task drafts the same way as live ones", () => { - const decoded = decodePersistedComposerState({ - schemaVersion: 1, - drafts: {}, - cloudAccountId: "account-1", - signedOutDrafts: { - "account-1": { - drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } }, - queuedMessages: [], - }, - }, - }); - const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {}); - expect(archived).toHaveLength(1); - expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/); - expect(archived[0]?.[1]).toMatchObject({ - text: "archived", - project: { environmentId: "environment-1", projectId: "project-1" }, - }); - }); - it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => { const now = "2026-09-05T12:00:00.000Z"; const [key, draft] = migrateLegacyNewTaskDraft( @@ -2132,34 +1890,6 @@ describe("mobile composer drafts", () => { expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); }); - it("hydrates the global sticky model selection", () => { - expect( - decodePersistedComposerState({ - schemaVersion: 1, - drafts: {}, - stickyModelSelection: { - instanceId: "codex", - model: "gpt-5.6-sol", - }, - }).stickyModelSelection, - ).toEqual({ - instanceId: "codex", - model: "gpt-5.6-sol", - }); - }); - - it("decodes model option memory from the composer document", () => { - expect( - decodePersistedComposerState({ - schemaVersion: 1, - drafts: {}, - modelOptionMemory: { - pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] }, - }, - }).modelOptionMemory, - ).toEqual({ pi: { "xai/grok-4.6": [{ id: "thinking", value: "xhigh" }] } }); - }); - it("merges persisted option memory without replacing newer choices", async () => { composerDraftFileMocks.setDocument({ schemaVersion: 1, @@ -2186,49 +1916,6 @@ describe("mobile composer drafts", () => { }); }); - it("waits for hydration before persisting the latest composer state", async () => { - vi.useFakeTimers(); - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": DRAFT, - }, - stickyModelSelection: { - instanceId: "codex", - model: "gpt-5.6-sol", - }, - }); - composerDraftFileMocks.blockRead(); - composerDraftFileMocks.resetWrites(); - - ensureComposerDraftsLoaded(); - await Promise.resolve(); - - setComposerDraftText("new-task:environment-1:project-1", "New prompt"); - await vi.advanceTimersByTimeAsync(200); - - expect(composerDraftFileMocks.getWrites()).toHaveLength(0); - - composerDraftFileMocks.releaseRead(); - - await vi.runAllTimersAsync(); - - expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({ - schemaVersion: 1, - drafts: { - "environment-1:thread-1": DRAFT, - "new-task:environment-1:project-1": { - text: "New prompt", - attachments: [], - }, - }, - stickyModelSelection: { - instanceId: "codex", - model: "gpt-5.6-sol", - }, - }); - }); - it("flush waits for pending hydration instead of clobbering disk", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ @@ -2301,27 +1988,6 @@ describe("mobile composer drafts", () => { }, ); - it("retries a failed debounced read on final flush without dropping saved drafts or new edits", async () => { - vi.useFakeTimers(); - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { "environment-1:saved": DRAFT }, - }); - const original = composerDraftFileMocks.getDocument(); - composerDraftFileMocks.setReadError(new Error("storage unavailable")); - setComposerDraftText("environment-1:new", "New edits"); - await vi.advanceTimersByTimeAsync(200); - expect(composerDraftFileMocks.getDocument()).toBe(original); - - composerDraftFileMocks.setReadError(null); - await flushComposerDrafts(); - - expect(JSON.parse(composerDraftFileMocks.getDocument()).drafts).toEqual({ - "environment-1:saved": DRAFT, - "environment-1:new": { text: "New edits", attachments: [] }, - }); - }); - it("serializes environment cleanup after an older queued write", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); @@ -2400,47 +2066,6 @@ describe("mobile composer drafts", () => { }); }); - it("drops draft-local model and workspace selections after sending a new task", () => { - const draftKey = "new-task:environment-1:project-1"; - const draft: ComposerDraft = { - text: "send this", - attachments: [], - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - workspaceSelection: { - mode: "worktree", - branch: "main", - worktreePath: null, - startFromOrigin: false, - }, - }; - - expect( - clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { - clearModelSelection: true, - clearWorkspaceSelection: true, - }), - ).toEqual({}); - }); - - it("reads the latest selector state synchronously for send", () => { - const draftKey = "environment-1:thread-1"; - const selectedDraft: ComposerDraft = { - text: "send this", - attachments: [], - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "xhigh" }], - }, - }; - appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); - - expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); - }); - it("drops another environment's upload stamp when a draft moves across machines", () => { const uploadedElsewhere: DraftComposerAttachment = { id: "image-1", @@ -2695,34 +2320,6 @@ describe("mobile composer drafts", () => { ).toEqual({}); }); - it("persists an async merge rollback with the sticky model selection", async () => { - const draftKey = "environment-1:thread-1"; - const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; - const merged: ComposerDraft = { - text: "typed before\n\nqueued text", - attachments: [], - }; - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { [draftKey]: merged }, - stickyModelSelection: { - instanceId: "codex", - model: "gpt-5.6-sol", - }, - }); - - await undoComposerDraftMerge(draftKey, snapshot, merged); - - expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ - schemaVersion: 1, - drafts: { [draftKey]: snapshot }, - stickyModelSelection: { - instanceId: "codex", - model: "gpt-5.6-sol", - }, - }); - }); - it("returns merge-written settings to the snapshot but keeps user-edited ones", () => { const draftKey = "environment-1:thread-1"; const snapshot: ComposerDraft = { diff --git a/apps/mobile/src/state/use-model-option-memory.test.ts b/apps/mobile/src/state/use-model-option-memory.test.ts deleted file mode 100644 index 0fe050e2dbed..000000000000 --- a/apps/mobile/src/state/use-model-option-memory.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; - -import { appAtomRegistry } from "./atom-registry"; -import { modelOptionMemoryAtom } from "./use-composer-drafts"; -import { - rememberModelOptions, - rememberedModelOptions, - withRememberedModelOptions, -} from "./use-model-option-memory"; - -const XHIGH = [{ id: "thinking", value: "xhigh" }] as const; -const HIGH = [{ id: "thinking", value: "high" }] as const; - -beforeEach(() => { - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - appAtomRegistry.set(modelOptionMemoryAtom, {}); -}); - -describe("model option memory state", () => { - it("records and looks up options per instance and model", () => { - rememberModelOptions("codex", "gpt-5.3-codex", [...XHIGH]); - rememberModelOptions("codex", "gpt-5.4", [...HIGH]); - expect(rememberedModelOptions("codex", "gpt-5.3-codex")).toEqual(XHIGH); - expect(rememberedModelOptions("codex", "gpt-5.4")).toEqual(HIGH); - expect(rememberedModelOptions("pi", "gpt-5.3-codex")).toBeUndefined(); - }); - - it("ignores empty option sets when recording", () => { - rememberModelOptions("codex", "gpt-5.4", []); - expect(rememberedModelOptions("codex", "gpt-5.4")).toBeUndefined(); - }); -}); - -describe("withRememberedModelOptions", () => { - it("restores the remembered options over descriptor defaults", () => { - rememberModelOptions("codex", "gpt-5.3-codex", [...XHIGH]); - expect( - withRememberedModelOptions({ - instanceId: "codex", - model: "gpt-5.3-codex", - options: [{ id: "reasoningEffort", value: "low" }], - }), - ).toEqual({ instanceId: "codex", model: "gpt-5.3-codex", options: XHIGH }); - }); - - it("keeps incoming selections that already match memory", () => { - rememberModelOptions("pi", "xai/grok-4.6", [...XHIGH]); - const selection = { instanceId: "pi", model: "xai/grok-4.6", options: [...XHIGH] }; - expect(withRememberedModelOptions(selection)).toBe(selection); - }); - - it("keeps incoming selections when nothing is remembered", () => { - const selection = { instanceId: "pi", model: "openai-codex/gpt-5.6-sol" }; - expect(withRememberedModelOptions(selection)).toBe(selection); - }); -}); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index 760bbd859cb8..8b662c1257d9 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -153,37 +153,6 @@ describe("thread Git mutation permissions", () => { }, ); - it.each(["create", "checkout", "commit on new branch"] as const)( - "requires task permission before %s", - async (operation) => { - const actions = useSelectedThreadGitActions(); - if (operation === "create") { - expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); - } - if (operation === "checkout") - expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); - if (operation === "commit on new branch") - await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }); - expect(state.branch).toBe("main"); - expect(state.thread.branch).toBe("main"); - expect(state.commits).toBe(0); - }, - ); - - it("rechecks task permission when a retained menu callback runs", async () => { - state.scopes.add(AuthOrchestrationOperateScope); - const actions = useSelectedThreadGitActions(); - state.scopes.delete(AuthOrchestrationOperateScope); - expect( - await actions.onCreateSelectedThreadWorktree({ - baseBranch: "main", - newBranch: "feature/task", - }), - ).toBeNull(); - expect(state.worktrees).toEqual([]); - expect(state.thread.worktreePath).toBeNull(); - }); - it("keeps ordinary commits and pushes available without task permission", async () => { const actions = useSelectedThreadGitActions(); await actions.onRunSelectedThreadGitAction({ action: "commit" }); @@ -234,15 +203,6 @@ describe("thread Git mutation permissions", () => { expect(state.statusRefreshes).toBe(1); }); - it("uses a newly granted task permission for a retained branch callback", async () => { - state.primaryScopes.clear(); - const actions = useSelectedThreadGitActions(); - state.scopes.add(AuthOrchestrationOperateScope); - await actions.onCreateSelectedThreadBranch("feature"); - expect(state.thread.branch).toBe("feature"); - expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); - }); - it.each(["create", "checkout", "worktree"] as const)( "returns the accepted %s result when only source-control permission is revoked after Git", async (operation) => { @@ -268,15 +228,4 @@ describe("thread Git mutation permissions", () => { expect(state.results).toEqual([]); }, ); - - it("returns a successful checkout when the requested branch is already selected", async () => { - state.scopes.add(AuthOrchestrationOperateScope); - - expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({ - refName: "main", - }); - expect(state.branch).toBe("main"); - expect(state.thread.branch).toBe("main"); - expect(state.results).toEqual([]); - }); }); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx index 11374b2e47c9..cfd1dd4052fe 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.tsx @@ -110,80 +110,4 @@ describe("useSelectedThreadGitActions", () => { { environmentId: "environment-1", input: { cwd: "/repo" } }, ]); }); - - it("refreshes again when the selected thread or cwd changes", async () => { - root = createRoot(document.createElement("div")); - act(() => root!.render()); - await flush(); - - harness.selection = { - selectedThread: { environmentId: "environment-1", id: "thread-2" }, - selectedThreadProject: { workspaceRoot: "/repo" }, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - act(() => rerender()); - await flush(); - - harness.worktree = { selectedThreadCwd: "/repo/worktree", selectedThreadWorktreePath: null }; - act(() => rerender()); - await flush(); - - expect(harness.refreshStatusCalls).toEqual([ - { environmentId: "environment-1", input: { cwd: "/repo" } }, - { environmentId: "environment-1", input: { cwd: "/repo" } }, - { environmentId: "environment-1", input: { cwd: "/repo/worktree" } }, - ]); - }); - - it("refreshes once after the environment reconnects and not while it is away", async () => { - root = createRoot(document.createElement("div")); - act(() => root!.render()); - await flush(); - - for (const connectionState of ["reconnecting", "connecting", "offline"]) { - harness.selection = { - ...harness.selection, - selectedEnvironmentRuntime: { connectionState }, - }; - act(() => rerender()); - await flush(); - } - expect(harness.refreshStatusCalls).toHaveLength(1); - - harness.selection = { - ...harness.selection, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - act(() => rerender()); - await flush(); - act(() => rerender()); - await flush(); - - expect(harness.refreshStatusCalls).toEqual([ - { environmentId: "environment-1", input: { cwd: "/repo" } }, - { environmentId: "environment-1", input: { cwd: "/repo" } }, - ]); - }); - - it("waits for the environment to connect before the first refresh", async () => { - harness.selection = { - ...harness.selection, - selectedEnvironmentRuntime: { connectionState: "connecting" }, - }; - root = createRoot(document.createElement("div")); - act(() => root!.render()); - await flush(); - expect(harness.refreshStatusCalls).toEqual([]); - - harness.selection = { - ...harness.selection, - selectedEnvironmentRuntime: { connectionState: "connected" }, - }; - act(() => rerender()); - await flush(); - - expect(harness.refreshStatusCalls).toEqual([ - { environmentId: "environment-1", input: { cwd: "/repo" } }, - ]); - }); }); diff --git a/apps/mobile/src/state/use-selected-thread-requests.test.tsx b/apps/mobile/src/state/use-selected-thread-requests.test.tsx deleted file mode 100644 index 2cdd86778684..000000000000 --- a/apps/mobile/src/state/use-selected-thread-requests.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const fixture = vi.hoisted(() => ({ - drafts: {} as Record, - uploads: {} as Record, - preparations: {} as Record, - preparationAtom: Symbol("preparation"), -})); -vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === "drafts" - ? fixture.drafts - : atom === "uploads" - ? fixture.uploads - : atom === fixture.preparationAtom - ? fixture.preparations - : {}, -})); -vi.mock("./use-composer-drafts", () => ({ - composerDraftsAtom: "all-drafts", - questionAttachmentDraftsAtom: () => "drafts", - clearComposerDraft: vi.fn(), -})); -vi.mock("./composer-attachment-uploads", async () => ({ - ...(await import("../lib/composerAttachmentUploadQueue")), - composerAttachmentUploadsAtom: "uploads", -})); -vi.mock("./question-attachments", async (importOriginal) => ({ - ...(await importOriginal()), - questionAttachmentPreparationAtom: fixture.preparationAtom, -})); -vi.mock("./entities", () => ({ - useServerConfigs: () => - new Map([ - [ - "environment-1", - { - environment: { - capabilities: { - questionAttachments: true, - attachmentUploads: true, - fileAttachments: { maxUploadBytes: 20_000_000 }, - }, - }, - }, - ], - ]), -})); -vi.mock("./session", () => ({ readEnvironmentScope: () => true })); -vi.mock("./threads", () => ({ threadEnvironment: {} })); -vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("./use-thread-selection", () => ({ - useThreadSelection: () => ({ - selectedThread: { environmentId: "environment-1", id: "thread-1" }, - }), -})); -vi.mock("./use-thread-detail", () => ({ - useSelectedThreadPendingRequests: () => ({ - approvals: [], - userInputs: [ - { - requestId: "request-1", - createdAt: "2026-09-08T00:00:00Z", - responseCapability: "live", - dismissible: false, - questions: ["first", "second"].map((id) => ({ - id, - header: id, - question: `Attach ${id} file`, - options: [], - allowCustomAnswer: true, - multiSelect: false, - })), - }, - ], - }), -})); - -import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { questionAttachmentDraftKey } from "./question-attachments"; -import { useSelectedThreadRequests } from "./use-selected-thread-requests"; - -const environmentId = EnvironmentId.make("environment-1"); -const key = (question: string) => - questionAttachmentDraftKey( - environmentId, - ThreadId.make("thread-1"), - ApprovalRequestId.make("request-1"), - question, - ); -function submitButtonMarkup() { - function Probe() { - const { activePendingUserInputAnswers } = useSelectedThreadRequests(); - return ; - } - return renderToStaticMarkup(); -} -beforeEach(() => { - fixture.preparations = {}; - fixture.drafts = Object.fromEntries( - ["first", "second"].map((id) => [ - key(id), - { - attachments: [ - { - id, - type: "file", - name: `${id}.txt`, - mimeType: "text/plain", - sizeBytes: 4, - fileUri: `file:///${id}.txt`, - }, - ], - }, - ]), - ); - fixture.uploads = { "environment-1:first": { status: "ready" } }; -}); -describe("question attachment submission readiness", () => { - it.each([ - undefined, - { status: "uploading", progress: 0.5 }, - { status: "failed", reason: "Offline" }, - ])("keeps Submit disabled until all question uploads finish: %j", (state) => { - if (state) fixture.uploads["environment-1:second"] = state; - expect(submitButtonMarkup()).toContain("disabled"); - fixture.uploads["environment-1:second"] = { status: "ready" }; - expect(submitButtonMarkup()).not.toContain("disabled"); - }); - it("ignores an upload in another environment", () => { - fixture.uploads["environment-1:second"] = { status: "ready" }; - fixture.uploads["environment-2:second"] = { status: "uploading", progress: 0.5 }; - expect(submitButtonMarkup()).not.toContain("disabled"); - }); - it("waits for attachment preparation even when uploads are ready", () => { - fixture.uploads["environment-1:second"] = { status: "ready" }; - fixture.preparations[key("first")] = 1; - expect(submitButtonMarkup()).toContain("disabled"); - }); -}); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index ff2e0bdf1eae..d4ed88624ba6 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -368,6 +368,7 @@ export function useSelectedThreadRequests() { return { activePendingApproval, + activePendingApprovals, activePendingUserInput, activePendingUserInputDrafts, activePendingUserInputAnswers, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 45fbd529f2c4..0c0d734bac09 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -9,6 +9,7 @@ import { pendingHandoffCoveredByModelChange, resolvePendingHandoff, } from "@t3tools/client-runtime/handoff"; +import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { deriveProviderSubagentStatus, deriveRunlessWorkStartedAt, @@ -125,6 +126,7 @@ import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -250,6 +252,11 @@ export function useThreadComposerState() { const selectedThreadMessages = selectedThreadProjection?.projection.messages; const selectedThreadAttempts = selectedThreadProjection?.projection.attempts; const selectedThreadNodes = selectedThreadProjection?.projection.nodes; + const checkpointSummaries = useAtomValue( + selectedThreadDetailRef === null + ? EMPTY_CHECKPOINT_SUMMARIES_ATOM + : environmentThreadDetails.checkpointSummariesAtom(selectedThreadDetailRef), + ); // A thread whose creation has not delivered its turn yet: the prompt only // exists in the outbox, so it is appended to whatever the server has. The // detail is usually present but empty during a worktree checkout, so this @@ -283,6 +290,7 @@ export function useThreadComposerState() { attempts: selectedThreadAttempts, nodes: selectedThreadNodes, workspaceRoot: selectedThreadCwd, + checkpointSummaries, cache: feedCache, }); const pendingAcknowledgments = acknowledgedMessages.filter( @@ -575,7 +583,10 @@ export function useThreadComposerState() { scheduleUnusedComposerAttachmentCleanup(draft.attachments); }; - const onSendMessage = async (followUpOverride?: ActiveTurnComposerAction) => { + const onSendMessage = async ( + followUpOverride?: ActiveTurnComposerAction, + beforeDispatch?: () => Promise, + ) => { if ( selectedThreadShell && selectedEnvironmentRuntime?.connectionState === "connected" && @@ -692,15 +703,35 @@ export function useThreadComposerState() { return null; } + if (beforeDispatch !== undefined) { + if (!(await beforeDispatch())) return null; + // A context paste still landing, or any draft or settings change while approvals were + // declined, means this snapshot is no longer what the user sent. + if (appAtomRegistry.get(composerContextImportsAtom)[threadKey]) return null; + const current = getComposerDraftSnapshot(threadKey); + if ( + current.text !== draft.text || + JSON.stringify(current.context) !== JSON.stringify(draft.context) || + JSON.stringify(current.modelSelection) !== JSON.stringify(draft.modelSelection) || + current.runtimeMode !== draft.runtimeMode || + current.interactionMode !== draft.interactionMode || + current.attachments.length !== attachments.length || + current.attachments.some((attachment, index) => attachment.id !== attachments[index]?.id) + ) + return null; + } + // Resolved here rather than at drain time: the outbox can deliver minutes // later, and the choice belongs to the moment the user pressed send. // Steering travels as "auto" so a turn that ends in the meantime degrades // to a queued run on the server instead of failing the delivery and // bouncing the message back into the draft. + // A redirect declined approvals to steer the waiting turn, not to queue behind it. + const activeTurnDefault = beforeDispatch !== undefined ? "steer" : followUpBehavior; const followUpAction = resolveComposerDispatchMode({ running: activeThreadBusy && canSteerActiveTurn, - alternateModifier: followUpOverride !== undefined && followUpOverride !== followUpBehavior, - activeTurnDefault: followUpBehavior, + alternateModifier: followUpOverride !== undefined && followUpOverride !== activeTurnDefault, + activeTurnDefault, }); const setupRunning = [selectedThreadActivityRun?.status, selectedThreadRuntime?.status].some( (status) => status === "preparing" || status === "starting", diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts index 93793cfee4f7..3bdab5f7a622 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -253,26 +253,6 @@ describe("queued PR checkout", () => { expect(remaining()).toEqual([queued]); }); - it("restores the PR draft when the reused worktree cannot reach the PR head", async () => { - state.grantedEnvironments.add("secondary"); - await state.manager.enqueue(queuedPullRequest()); - state.checkout.mockResolvedValueOnce( - AsyncResult.success({ ...preparedCheckout, isOnPullRequestHead: false }), - ); - await runDrain(); - expect(state.start).not.toHaveBeenCalled(); - expect(remaining()).toEqual([]); - const { updateComposerDraftSettings } = await import("./use-composer-drafts"); - expect(updateComposerDraftSettings).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - workspaceSelection: expect.objectContaining({ - pullRequestReference: queuedPullRequest().creation?.pullRequestReference, - }), - }), - ); - }); - it("stops launch if operation access is revoked while checking out the PR", async () => { state.grantedEnvironments.add("secondary"); const queued = queuedPullRequest(); @@ -340,8 +320,6 @@ describe("queued task operation access", () => { it.each([ { isCreation: false, accepted: false }, - { isCreation: true, accepted: false }, - { isCreation: false, accepted: true }, { isCreation: true, accepted: true }, ])( "preserves a rejected queue but removes an accepted turn after access loss (new task: $isCreation, accepted: $accepted)", diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 9c8678d37683..f62686baf236 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -1,7 +1,6 @@ import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { CommandId, - ComposerContextId, EnvironmentId, MessageId, ProjectId, @@ -139,15 +138,8 @@ vi.mock("./thread-outbox", async () => { }); import { appAtomRegistry } from "./atom-registry"; -import { - clearPendingThreadCreationOutcome, - pendingThreadCreationOutcomesAtom, -} from "./pending-thread-creation"; -import { - clearThreadComposerError, - setThreadComposerError, - threadComposerErrorsAtom, -} from "./thread-composer-error"; +import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; +import { clearThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; import type { QueuedThreadMessage } from "./thread-outbox-model"; import * as composerDrafts from "./use-composer-drafts"; import { recoverFailedThreadDraft } from "./recover-failed-thread-draft"; @@ -284,32 +276,6 @@ describe("thread outbox attachment preparation", () => { expect(remainingMessages()).toEqual([edited]); }); - it("keeps an unchanged queued payload ready after attachment reuse", async () => { - const message = withReusedFileUpload( - queuedMessage({ - messageId: "message-reused-upload-current", - text: "unchanged text", - fileUri: "file:///documents/t3-composer-attachments/current.pdf", - }), - "pending-reused-upload", - ); - harness.prepareTurnAttachments.mockResolvedValueOnce({ - status: "ready", - attachments: [], - draftAttachments: message.attachments, - pendingAttachmentIds: ["pending-reused-upload"], - }); - await harness.manager.enqueue(message); - const revision = harness.manager.revisionOf(message.messageId); - appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); - - await expect(prepareQueuedMessageAttachments(message)).resolves.toMatchObject({ - status: "ready", - persistedMessage: message, - deliveryRevision: revision, - }); - }); - it("uses the known next revision after persisting uploaded references", async () => { const message = queuedMessage({ messageId: "message-new-upload-revision", @@ -474,23 +440,6 @@ describe("thread outbox drain delivery cleanup", () => { expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); }); - it("clears an error about the delivered message but keeps one about another message", async () => { - const threadKey = "environment-1:thread-1"; - const retried = queuedMessage({ messageId: "message-retried", text: "retried" }); - await harness.manager.enqueue(retried); - setThreadComposerError(threadKey, "could not be restored", retried.messageId); - - await completeQueuedMessageDelivery(retried, harness.manager.revisionOf(retried.messageId)); - expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]).toBeUndefined(); - - const other = queuedMessage({ messageId: "message-other", text: "other" }); - await harness.manager.enqueue(other); - setThreadComposerError(threadKey, "rejected", "message-rejected"); - - await completeQueuedMessageDelivery(other, harness.manager.revisionOf(other.messageId)); - expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe("rejected"); - }); - it("keeps a delivered message when its editor opens during storage removal", async () => { const message = queuedMessage({ messageId: "message-editor-removal-race", @@ -640,31 +589,6 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { - it("preserves inline context from setup edits when reopening a failed task", async () => { - const message = queuedMessage({ messageId: "failed-context", text: "Original prompt" }); - const sourceKey = `${message.environmentId}:${message.threadId}`; - const targetKey = "new-task:restored-failed-context"; - const record = { - version: 1 as const, - kind: "mention" as const, - contextId: ComposerContextId.make("setup-file"), - label: "Checkout.tsx", - path: "src/Checkout.tsx", - }; - const context = { version: 1 as const, records: [record] }; - const text = "[Checkout.tsx](t3-context://v1/mention/setup-file)"; - appAtomRegistry.set(composerDrafts.composerDraftsAtom, { - [targetKey]: { text: message.text, attachments: [] }, - [sourceKey]: { text, context, attachments: [] }, - }); - await recoverFailedThreadDraft(message); - expect(composerDrafts.getComposerDraftSnapshot(targetKey)).toMatchObject({ - text: `Original prompt\n\n${text}`, - context, - }); - expect(composerDrafts.getComposerDraftSnapshot(sourceKey).context).toBeUndefined(); - }); - it("reopens a rejected task with setup edits and every attachment, even above the send cap", async () => { const message = queuedMessage({ messageId: "failed-setup", text: "Original prompt" }); const sourceKey = `${message.environmentId}:${message.threadId}`; @@ -752,57 +676,6 @@ describe("thread outbox recovery rollback", () => { ).toEqual({ kind: "failed", message, reason: "rejected by server" }); }); - it("drops an earlier recovery error once a rejected new task is restored", async () => { - const message: QueuedThreadMessage = { - ...queuedMessage({ messageId: "message-creation-retried", text: "new task text" }), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "local", - branch: null, - worktreePath: null, - }, - }; - await harness.manager.enqueue(message); - harness.draftFile.setWriteError(new Error("disk full")); - await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( - "retry", - ); - const threadKey = `${message.environmentId}:${message.threadId}`; - expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.messageId).toBe( - message.messageId, - ); - - harness.draftFile.setWriteError(null); - await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( - "restored", - ); - - expect(appAtomRegistry.get(threadComposerErrorsAtom)).toEqual({}); - expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[threadKey]?.kind).toBe("failed"); - }); - - it("keeps a failed outcome until its thread screen consumes it", async () => { - const message: QueuedThreadMessage = { - ...queuedMessage({ messageId: "message-creation-kept", text: "new task text" }), - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "local", - branch: null, - worktreePath: null, - }, - }; - await harness.manager.enqueue(message); - await restoreRejectedQueuedMessage(message, "rejected by server"); - - const key = `${message.environmentId}:${message.threadId}`; - expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]?.kind).toBe("failed"); - - clearPendingThreadCreationOutcome(key); - expect(appAtomRegistry.get(pendingThreadCreationOutcomesAtom)[key]).toBeUndefined(); - }); - it("does not record a creation outcome for a rejected follow-up message", async () => { const message = queuedMessage({ messageId: "message-followup-restore", text: "follow up" }); await harness.manager.enqueue(message); diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 3f69dcba5461..f238f1bdeb92 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,54 +1,7 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - presentThreadLinkedPullRequests, - presentThreadPr, - resolveThreadPrBadgeAction, - resolveThreadPrSource, -} from "./thread-pr-presentation"; - -const pullRequest: NonNullable = { - number: 3774, - title: "Desktop-style pull request indicator", - url: "https://github.com/t3tools/t3code/pull/3774", - baseRef: "main", - headRef: "codex/desktop-style-pr-indicator", - state: "merged", -}; - -describe("presentThreadPr", () => { - it("uses the compact pull request number label without a hash prefix", () => { - expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ - label: "3774", - accessibilityLabel: "#3774 pull request merged", - textClassName: "text-adaptive-violet-600-400", - }); - }); - - it("uses merge-request terminology for GitLab", () => { - expect( - presentThreadPr(pullRequest, { - kind: SourceControlProviderKind.make("gitlab"), - name: "GitLab", - baseUrl: "https://gitlab.com", - }), - ).toMatchObject({ - label: "3774", - accessibilityLabel: "#3774 merge request merged", - }); - }); - - it("uses gray for draft pull requests", () => { - expect( - presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), - ).toMatchObject({ - accessibilityLabel: "#3774 pull request draft", - textClassName: "text-foreground-muted", - }); - }); -}); +import { presentThreadLinkedPullRequests, resolveThreadPrSource } from "./thread-pr-presentation"; function linkedPr( number: number, @@ -76,57 +29,6 @@ function linkedPr( } describe("presentThreadLinkedPullRequests", () => { - it("renders unsynced links with neutral pending status", () => { - expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({ - number: 1, - label: "1", - state: null, - textClassName: "text-foreground-muted", - accessibilityLabel: "#1 pull request status pending", - }); - }); - - it("counts unrelated links without labelling them a stack", () => { - expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({ - kind: "pull-request", - label: "+2", - others: 1, - state: "open", - isDraft: false, - textClassName: "text-adaptive-emerald-600-400", - }); - }); - - it.each([ - ["closed", false, "closed", false, "closed", false, "text-adaptive-rose-600-400"], - ["open", true, "open", true, "open", true, "text-foreground-muted"], - ["open", true, "open", false, "open", false, "text-adaptive-emerald-600-400"], - ["closed", false, "open", false, "open", false, "text-adaptive-emerald-600-400"], - ["merged", false, "merged", false, "merged", false, "text-adaptive-violet-600-400"], - ["closed", false, "merged", false, "closed", false, "text-adaptive-rose-600-400"], - ] as const)( - "colors linked %s (draft %s) and %s (draft %s) by their aggregate state", - (firstState, firstDraft, secondState, secondDraft, state, isDraft, textClassName) => { - const first = linkedPr(1); - const second = linkedPr(2); - expect( - presentThreadLinkedPullRequests([ - { ...first, snapshot: { ...first.snapshot!, state: firstState, isDraft: firstDraft } }, - { - ...second, - snapshot: { ...second.snapshot!, state: secondState, isDraft: secondDraft }, - }, - ]), - ).toMatchObject({ - label: "+2", - state, - isDraft, - textClassName, - accessibilityLabel: `2 linked pull requests, overall ${isDraft ? "draft" : state}`, - }); - }, - ); - it("uses the top of a derived stack even when its bottom was linked later", () => { const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" }); const top = linkedPr(2); @@ -140,51 +42,6 @@ describe("presentThreadLinkedPullRequests", () => { ]), ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url }); }); - - it("hides dismissed stack members", () => { - expect( - presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]), - ).toBeNull(); - }); - - it("retains merged state from the persisted snapshot", () => { - const link = linkedPr(1); - expect( - presentThreadLinkedPullRequests([ - { ...link, snapshot: { ...link.snapshot!, state: "merged" } }, - ]), - ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" }); - }); -}); - -describe("resolveThreadPrBadgeAction", () => { - it("opens a single linked or branch pull request directly", () => { - expect(resolveThreadPrBadgeAction(presentThreadLinkedPullRequests([linkedPr(1)])!)).toEqual({ - kind: "open", - accessibilityLabel: "Open #1 pull request open", - }); - expect(resolveThreadPrBadgeAction(presentThreadPr(pullRequest, null))).toMatchObject({ - kind: "open", - }); - }); - - it("lists unrelated linked pull requests", () => { - expect( - resolveThreadPrBadgeAction(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])!), - ).toEqual({ - kind: "list", - accessibilityLabel: "Show 2 linked pull requests, overall open", - }); - }); - - it("lists a stack even though the badge shows its top pull request", () => { - const top = linkedPr(2); - const stack = presentThreadLinkedPullRequests([ - linkedPr(1), - { ...top, snapshot: { ...top.snapshot!, baseBranch: "change-1" } }, - ])!; - expect(resolveThreadPrBadgeAction(stack)).toMatchObject({ kind: "list" }); - }); }); describe("resolveThreadPrSource compatibility", () => { @@ -205,13 +62,6 @@ describe("resolveThreadPrSource compatibility", () => { ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); }); - it("keeps the legacy reference for a server without either capability", () => { - expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({ - linkedPresentation: null, - pullRequestRef: legacyRef, - }); - }); - it("ignores stale snapshots after reconnecting to a server without array support", () => { expect( resolveThreadPrSource( diff --git a/apps/mobile/src/state/workspace-connection-atoms.test.ts b/apps/mobile/src/state/workspace-connection-atoms.test.ts index 96e0b4ef934d..64c7256ca89f 100644 --- a/apps/mobile/src/state/workspace-connection-atoms.test.ts +++ b/apps/mobile/src/state/workspace-connection-atoms.test.ts @@ -62,6 +62,7 @@ describe("workspace connection subscriptions", () => { networkStatus: get(h.network), environments: [projectWorkspaceEnvironment(ID, get(h.source)!)], shellSummary: get(shell), + hasSyncingConnectedEnvironment: false, }), ); const initial = h.registry.get(h.stateAtom); @@ -95,88 +96,4 @@ describe("workspace connection subscriptions", () => { h.registry.dispose(); } }); - - it("propagates errors, offline state, disabling and reconnection", () => { - const h = harness(); - try { - expect(h.registry.get(h.stateAtom).connectionState).toBe("connected"); - const failed = { - ...presentation(), - connection: { phase: "error" as const, error: "Connection failed", traceId: "trace-1" }, - }; - h.registry.set(h.source, failed); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - connectionState: "error", - connectionError: "Connection failed", - hasReadyEnvironment: false, - }); - expect(h.registry.get(h.environmentsAtom)[0]?.connectionErrorTraceId).toBe("trace-1"); - h.registry.set(h.source, { ...failed, entry: { ...failed.entry, enabled: false } }); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - hasConnections: true, - connectionState: "available", - connectionError: null, - }); - h.registry.set(h.source, { - ...presentation(), - connection: { phase: "reconnecting", error: null, traceId: null }, - }); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - hasConnectingEnvironment: true, - connectionState: "reconnecting", - }); - h.registry.set(h.source, presentation()); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - hasReadyEnvironment: true, - hasConnectingEnvironment: false, - }); - h.registry.set(h.network, "offline"); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - connectionState: "offline", - hasReadyEnvironment: false, - }); - h.registry.set(h.network, "online"); - expect(h.registry.get(h.stateAtom).connectionState).toBe("connected"); - } finally { - h.registry.dispose(); - } - }); - - it("updates labels and endpoints, removes environments, and finishes catalog loading", () => { - const h = harness(); - try { - h.registry.set(h.catalog, { isReady: false, entries: new Map() }); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - isLoadingConnections: true, - hasConnections: false, - }); - h.registry.set(h.source, { - ...presentation(), - entry: { - ...presentation().entry, - target: new PrimaryConnectionTarget({ - environmentId: ID, - label: "Renamed", - httpBaseUrl: "https://new.example.test", - wsBaseUrl: "wss://new.example.test", - }), - }, - }); - h.registry.set(h.catalog, { isReady: true, entries: new Map([[ID, presentation().entry]]) }); - expect(h.registry.get(h.environmentsAtom)[0]).toMatchObject({ - environmentLabel: "Renamed", - displayUrl: "https://new.example.test", - }); - expect(h.registry.get(h.stateAtom).isLoadingConnections).toBe(false); - h.registry.set(h.catalog, { isReady: true, entries: new Map() }); - expect(h.registry.get(h.stateAtom)).toMatchObject({ - hasConnections: false, - connectionState: "available", - hasReadyEnvironment: false, - }); - expect(h.registry.get(h.environmentsAtom)).toEqual([]); - } finally { - h.registry.dispose(); - } - }); }); diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts index edc253e243a6..46fafdf265d5 100644 --- a/apps/mobile/src/state/workspace.ts +++ b/apps/mobile/src/state/workspace.ts @@ -1,7 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { useMemo } from "react"; +import { Atom } from "effect/reactivity"; -import { environmentShellSummaryAtom } from "./shell"; +import { environmentShell, environmentShellSummaryAtom } from "./shell"; import { projectWorkspaceState } from "./workspaceModel"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations } from "./presentation"; @@ -13,6 +14,16 @@ export const workspaceConnections = createWorkspaceConnectionAtoms({ presentationAtom: environmentPresentations.presentationAtom, }); +// A disconnected environment's retries also leave its shell synchronizing. +const hasSyncingConnectedEnvironmentAtom = Atom.make((get) => + get(workspaceConnections.environmentsAtom).some( + (environment) => + environment.isEnabled && + environment.connectionState === "connected" && + get(environmentShell.stateValueAtom(environment.environmentId)).status === "synchronizing", + ), +).pipe(Atom.withLabel("mobile:has-syncing-connected-environment")); + export function useWorkspaceEnvironments() { return useAtomValue(workspaceConnections.environmentsAtom); } @@ -30,6 +41,7 @@ export function useWorkspaceState() { const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); const projectedEnvironments = useWorkspaceEnvironments(); const shellSummary = useAtomValue(environmentShellSummaryAtom); + const hasSyncingConnectedEnvironment = useAtomValue(hasSyncingConnectedEnvironmentAtom); const state = useMemo( () => projectWorkspaceState({ @@ -37,8 +49,9 @@ export function useWorkspaceState() { networkStatus, environments: projectedEnvironments, shellSummary, + hasSyncingConnectedEnvironment, }), - [isReady, networkStatus, projectedEnvironments, shellSummary], + [isReady, networkStatus, projectedEnvironments, shellSummary, hasSyncingConnectedEnvironment], ); return { diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts index 60bc6830321f..3cc0f0dc1524 100644 --- a/apps/mobile/src/state/workspaceModel.test.ts +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -63,13 +63,6 @@ const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { }; describe("mobile workspace projection", () => { - it("preserves explicit offline state without presenting it as a connection error", () => { - const projected = projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("offline")); - - expect(projected.connectionState).toBe("offline"); - expect(projected.connectionError).toBeNull(); - }); - it("reports offline before stale connected presentations", () => { const environments = [projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("connected"))]; const state = projectWorkspaceState({ @@ -77,6 +70,7 @@ describe("mobile workspace projection", () => { networkStatus: "offline", environments, shellSummary: EMPTY_SHELL_SUMMARY, + hasSyncingConnectedEnvironment: false, }); expect(state.connectionState).toBe("offline"); @@ -84,24 +78,6 @@ describe("mobile workspace projection", () => { expect(state.hasReadyEnvironment).toBe(false); }); - it("projects reconnecting environments dynamically from active phases", () => { - const environments = [ - projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("reconnecting")), - projectWorkspaceEnvironment(EnvironmentId.make("environment-2"), environment("connected")), - ]; - const state = projectWorkspaceState({ - isReady: true, - networkStatus: "online", - environments, - shellSummary: EMPTY_SHELL_SUMMARY, - }); - - expect(state.connectingEnvironments).toHaveLength(1); - expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting"); - expect(state.hasConnectingEnvironment).toBe(true); - expect(state.hasReadyEnvironment).toBe(true); - }); - it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { const environments = [projectWorkspaceEnvironment(ENVIRONMENT_ID, environment("reconnecting"))]; const state = projectWorkspaceState({ @@ -109,10 +85,12 @@ describe("mobile workspace projection", () => { networkStatus: "online", environments, shellSummary: CACHED_SHELL_SUMMARY, + hasSyncingConnectedEnvironment: false, }); expect(state.hasLoadedShellSnapshot).toBe(true); expect(state.hasPendingShellSnapshot).toBe(true); + expect(state.hasSyncingConnectedEnvironment).toBe(false); expect(state.hasReadyEnvironment).toBe(false); expect(state.connectionState).toBe("reconnecting"); }); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 5584a06a8c76..efd9071927ff 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -25,6 +25,7 @@ export interface WorkspaceConnectionState { export interface WorkspaceState extends WorkspaceConnectionState { readonly hasLoadedShellSnapshot: boolean; readonly hasPendingShellSnapshot: boolean; + readonly hasSyncingConnectedEnvironment: boolean; readonly shellSnapshotError: string | null; } @@ -92,11 +93,13 @@ export function projectWorkspaceState(input: { readonly networkStatus: NetworkStatus; readonly environments: ReadonlyArray; readonly shellSummary: EnvironmentShellSummary; + readonly hasSyncingConnectedEnvironment: boolean; }): WorkspaceState { return { ...projectWorkspaceConnectionState(input), hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, + hasSyncingConnectedEnvironment: input.hasSyncingConnectedEnvironment, shellSnapshotError: input.shellSummary.firstError, }; } diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts deleted file mode 100644 index 5585ff4237e7..000000000000 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("@expo/ui/swift-ui", () => ({ - HStack: "HStack", - Image: "Image", - Spacer: "Spacer", - Text: "Text", - VStack: "VStack", - ZStack: "ZStack", -})); - -vi.mock("@expo/ui/swift-ui/modifiers", () => ({ - activityBackgroundTint: (value: unknown) => value, - font: (value: unknown) => value, - foregroundStyle: (value: unknown) => value, - frame: (value: unknown) => value, - layoutPriority: (value: unknown) => value, - lineLimit: (value: unknown) => value, - padding: (value: unknown) => value, - resizable: (value: unknown) => value, - widgetURL: (value: unknown) => ({ widgetURL: value }), -})); - -vi.mock("expo-widgets", () => ({ - createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), -})); - -import { - AgentActivity, - type AgentActivityProps, - type AgentActivityRowProps, -} from "./AgentActivity"; - -function makeRow(overrides: Partial): AgentActivityRowProps { - return { - environmentId: "env-1", - threadId: "thread-1", - projectTitle: "Project", - threadTitle: "Thread", - modelTitle: "gpt-5.4", - phase: "running", - status: "Working", - updatedAt: "2026-05-25T13:07:00.000Z", - deepLink: "/threads/env-1/thread-1", - ...overrides, - }; -} - -const props = { - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: "2026-05-25T13:07:00.000Z", - activities: [], -} satisfies AgentActivityProps; - -const environment = { - colorScheme: "dark", - isLuminanceReduced: false, -} as const; - -const lightEnvironment = { - colorScheme: "light", - isLuminanceReduced: false, -} as const; - -describe("AgentActivity widget layout", () => { - it("tints each row by its own phase using the web sidebar's dark palette", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({}), - makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), - ], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("#7dd3fc"); - expect(banner).toContain("#fcd34d"); - }); - - it("degrades in-flight rows once the system marks the activity stale", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({}), - makeRow({ threadId: "thread-2", phase: "completed", status: "Done" }), - ], - }, - { ...environment, isStale: true } as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("Agent status out of date"); - expect(banner).toContain("Out of date"); - expect(banner).not.toContain("#7dd3fc"); - expect(banner).toContain("Done"); - expect(JSON.stringify(layout.minimal)).not.toContain("2"); - }); - - it("switches to the web sidebar's light palette when the scheme is light", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({}), - makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }), - ], - }, - lightEnvironment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("#0284c7"); - expect(banner).toContain("#d97706"); - expect(banner).not.toContain("#7dd3fc"); - expect(banner).not.toContain("#fcd34d"); - }); - - it("orders rows attention-first in the banner", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({ threadTitle: "Working thread" }), - makeRow({ - threadId: "thread-2", - threadTitle: "Blocked thread", - phase: "waiting_for_approval", - status: "Approval", - }), - ], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1); - expect(banner.indexOf("Blocked thread")).toBeLessThan(banner.indexOf("Working thread")); - }); - - it("summarizes the attention count in the banner header", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 3, - activities: [ - makeRow({}), - makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), - ], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("3 active agents"); - expect(banner).toContain("1 needs attention"); - }); - - it("uses the attention tint for the compact presentations when a row needs input", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({}), - makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }), - ], - }, - environment as never, - ); - expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); - expect(JSON.stringify(layout.compactTrailing)).toContain("Input"); - expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc"); - }); - - it("deep links the banner to the row that needs attention", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 2, - activities: [ - makeRow({}), - makeRow({ - threadId: "thread-2", - phase: "waiting_for_approval", - status: "Approval", - deepLink: "/threads/env-1/thread-2", - }), - ], - }, - environment as never, - ); - expect(JSON.stringify(layout.banner)).toContain( - '"widgetURL":"t3code://threads/env-1/thread-2"', - ); - }); - - it("deep links the banner to the first row when nothing needs attention", () => { - const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never); - expect(JSON.stringify(layout.banner)).toContain( - '"widgetURL":"t3code://threads/env-1/thread-1"', - ); - }); - - it("omits the deep link for unsafe paths and empty aggregates", () => { - expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL"); - expect( - JSON.stringify( - AgentActivity( - { ...props, activities: [makeRow({ deepLink: "//evil.example" })] }, - environment as never, - ), - ), - ).not.toContain("widgetURL"); - }); - - it("leads with the outcome instead of a zero count when nothing is active", () => { - const layout = AgentActivity( - { - ...props, - subtitle: "Agent work completed", - activeCount: 0, - activities: [makeRow({ phase: "completed", status: "Done" })], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("Agent work completed"); - expect(banner).not.toContain("0 active"); - expect(banner).toContain("#6ee7b7"); - expect(JSON.stringify(layout.compactTrailing)).toContain("Done"); - expect(JSON.stringify(layout.compactTrailing)).not.toContain("0 active"); - expect(JSON.stringify(layout.expandedLeading)).toContain("Done"); - expect(JSON.stringify(layout.minimal)).toContain("checkmark.circle.fill"); - expect(JSON.stringify(layout.bannerSmall)).toContain("Done"); - }); - - it("reads Failed when the finished work ended in failure", () => { - const layout = AgentActivity( - { - ...props, - subtitle: "Agent work failed", - activeCount: 0, - activities: [makeRow({ phase: "failed", status: "Failed" })], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("Agent work failed"); - expect(banner).toContain("#fca5a5"); - expect(JSON.stringify(layout.compactTrailing)).toContain("Failed"); - expect(JSON.stringify(layout.expandedLeading)).toContain("Failed"); - expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); - }); - - it("lets a failure dominate mixed finished outcomes across every presentation", () => { - const layout = AgentActivity( - { - ...props, - subtitle: "Agent work completed", - activeCount: 0, - activities: [ - makeRow({ phase: "completed", status: "Done" }), - makeRow({ threadId: "thread-2", phase: "failed", status: "Failed" }), - ], - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - expect(banner).toContain("Agent work failed"); - expect(banner).not.toContain("Agent work completed"); - expect(banner).toContain("#fca5a5"); - expect(JSON.stringify(layout.compactTrailing)).toContain("Failed"); - expect(JSON.stringify(layout.expandedLeading)).toContain("Failed"); - expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill"); - }); - - it("renders up to five rows in the banner", () => { - const layout = AgentActivity( - { - ...props, - activeCount: 6, - activities: [1, 2, 3, 4, 5, 6].map((n) => - makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }), - ), - }, - environment as never, - ); - const banner = JSON.stringify(layout.banner); - for (const visible of [1, 2, 3, 4, 5]) { - expect(banner).toContain(`Thread ${visible}`); - } - expect(banner).not.toContain("Thread 6"); - }); -}); diff --git a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts b/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts deleted file mode 100644 index db2efd5da17b..000000000000 --- a/apps/mobile/src/widgets/SubscriptionUsage.android.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("@expo/ui/jetpack-compose", () => ({ - Button: "Button", - Column: "Column", - LinearProgressIndicator: "LinearProgressIndicator", - Text: "Text", - getMaterialColors: ({ scheme }: { scheme: string }) => ({ - surface: `${scheme}-surface`, - onSurface: `${scheme}-onSurface`, - onSurfaceVariant: `${scheme}-onSurfaceVariant`, - surfaceVariant: `${scheme}-surfaceVariant`, - primary: `${scheme}-primary`, - error: `${scheme}-error`, - }), -})); - -vi.mock("@expo/ui/jetpack-compose/modifiers", () => ({ - fillMaxSize: () => "fillMaxSize", - fillMaxWidth: () => "fillMaxWidth", - height: (value: number) => ({ height: value }), - padding: (...values: number[]) => ({ padding: values }), - paddingAll: (value: number) => ({ paddingAll: value }), -})); - -vi.mock("expo-widgets", () => ({ - createWidget: vi.fn((name: string, layout: unknown) => ({ layout, name })), -})); - -import { SubscriptionUsage } from "./SubscriptionUsage.android"; -import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; - -const now = Date.parse("2026-09-05T12:00:00.000Z"); -const provider = { - name: "Codex", - detail: "Subscription remaining", - windows: [ - { kind: "session", label: "5 hours", remaining: 60, reset: "Next reset Sep 5, 5:00 PM" }, - { kind: "weekly", label: "Weekly", remaining: 8, reset: "Next reset Sep 9, 9:00 AM" }, - ], - expiresAt: now + 60_000, - totalWindows: 2, -} satisfies SubscriptionUsageSnapshot["providers"][number]; -const snapshot = { - checkedAt: now, - url: "t3code-dev://settings/usage?tab=limits", - providers: [provider, { ...provider, name: "Claude" }], -} satisfies SubscriptionUsageSnapshot; - -function render(props: SubscriptionUsageSnapshot, colorScheme: "light" | "dark" = "dark") { - vi.setSystemTime(now); - return JSON.stringify(SubscriptionUsage(props, { colorScheme, configuration: undefined })); -} - -describe("SubscriptionUsage Android layout", () => { - it("renders each window with its remaining share, bar, and reset", () => { - const tree = render(snapshot); - expect(tree).toContain("5 hours · 60% left"); - expect(tree).toContain('"progress":0.6'); - expect(tree).toContain("Next reset Sep 5, 5:00 PM"); - expect(tree).toContain('"progress":0.08'); - expect(tree).toContain('"color":"dark-error"'); - expect(tree).toContain("As of "); - }); - - it("drops the bars and asks for a refresh once the snapshot expires", () => { - const tree = render({ - ...snapshot, - providers: [{ ...provider, expiresAt: now - 1 }], - }); - expect(tree).toContain("Open T3 to refresh"); - expect(tree).not.toContain("LinearProgressIndicator"); - expect(tree).not.toContain("more in T3"); - }); - - it("counts the quotas that did not fit", () => { - const tree = render({ ...snapshot, providers: [{ ...provider, totalWindows: 5 }] }); - expect(tree).toContain("3 more in T3"); - }); - - it("keeps quotas without an expiry deadline visible", () => { - const tree = render({ ...snapshot, providers: [{ ...provider, expiresAt: 0 }] }); - expect(tree).toContain("5 hours · 60% left"); - expect(tree).not.toContain("Open T3 to refresh"); - }); - - it("invites connecting when nothing has been checked", () => { - const tree = render({ checkedAt: 0, providers: [] }, "light"); - expect(tree).toContain("Tap to connect in T3"); - expect(tree).not.toContain("As of "); - expect(tree).toContain('"containerColor":"light-surface"'); - }); -}); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index fbd5938d36a7..abc4fb0ed0c9 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -6,30 +6,12 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { getMobileThemeRuntimeVariables } from "../lib/mobileThemeVariables"; -import { createWidgetTheme } from "./widgetTheme"; import { - type SubscriptionUsageSnapshot, buildSubscriptionUsageSnapshot, createWidgetRefresher, WIDGET_REFRESH_INTERVAL, - subscriptionUsageTimeline, } from "./subscriptionUsageSnapshot"; -function contrastRatio(first: string, second: string) { - const luminance = (color: string) => { - if (!/^#[0-9a-f]{6}(?:ff)?$/i.test(color)) throw new Error(`Expected opaque hex: ${color}`); - const linear = [1, 3, 5].map((offset) => { - const channel = Number.parseInt(color.slice(offset, offset + 2), 16) / 255; - return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!; - }; - const a = luminance(first); - const b = luminance(second); - return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); -} - const checkedAt = "2026-09-05T12:00:00.000Z"; const now = Date.parse(checkedAt); const window = { @@ -82,64 +64,6 @@ describe("subscription widget snapshots", () => { expect(snapshot.url).toBe(deepLink); expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); }); - it("clears data after removing environments", () => { - expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]); - }); - it.each<{ name: string; overrides: Partial }>([ - { name: "disabled", overrides: { enabled: false } }, - { - name: "missing", - overrides: { installed: false, status: "error", usageLimits: undefined }, - }, - { - name: "API-key", - overrides: { - usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, - }, - }, - ])("hides $name providers", ({ overrides }) => { - expect( - buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers, - ).toEqual([]); - }); - it.each([ - { name: "Codex", driver: "codex" }, - { name: "Claude", driver: "claudeAgent" }, - ])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([ - provider({ - instanceId: ProviderInstanceId.make(driver), - driver: ProviderDriverKind.make(driver), - }), - provider({ - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - usageLimits: undefined, - }), - ]), - deepLink, - ); - expect(snapshot.providers).toHaveLength(1); - expect(snapshot.providers[0]).toMatchObject({ - name, - totalWindows: 1, - windows: [{ remaining: 60 }], - }); - expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([ - expect.objectContaining({ name, totalWindows: 0, windows: [] }), - ]); - }); - it("keeps an enabled provider visible before its first usage read", () => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([provider({ usageLimits: undefined })]), - deepLink, - ); - expect(snapshot.checkedAt).toBe(0); - expect(snapshot.providers).toEqual([ - { name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }, - ]); - }); it("uses upstream deduplication for a native account also present in a proxy hub", () => { const input = new Map([ [ @@ -176,29 +100,6 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.name).toBe("Codex"); expect(JSON.stringify(snapshot)).not.toContain("example.com"); }); - it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([ - provider({ - usageLimits: { - ...limits, - unavailable: { reason: "probeFailed", message: "token secret" }, - }, - }), - ]), - deepLink, - ); - expect(snapshot.providers[0]?.windows).toEqual([]); - expect(JSON.stringify(snapshot)).not.toContain("token secret"); - expect( - buildSubscriptionUsageSnapshot( - presentations([ - provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }), - ]), - deepLink, - ).providers[0]?.windows[0]?.remaining, - ).toBe(100); - }); it("bounds OS storage and puts the most constrained windows first", () => { const windows = Array.from({ length: 20 }, (_, index) => ({ ...window, @@ -213,100 +114,6 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.totalWindows).toBe(20); expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); }); - it("includes every limit for the scrollable Android widget", () => { - const windows = Array.from({ length: 20 }, (_, index) => ({ - ...window, - id: `${index}`, - usedPercent: index * 5, - })); - const snapshot = buildSubscriptionUsageSnapshot( - presentations([provider({ usageLimits: { checkedAt, windows } })]), - deepLink, - Infinity, - ); - expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual( - Array.from({ length: 20 }, (_, index) => 5 + index * 5), - ); - }); - it("marks unknown or distant reset times stale after fifteen minutes", () => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([ - provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }), - ]), - deepLink, - ); - expect(snapshot.providers[0]?.expiresAt).toBe(now + 15 * 60_000); - expect(snapshot.providers[0]?.windows[0]?.reset).toBe("Reset time unavailable"); - }); - it("preserves the saved appearance through a reset without inventing a zero quota", () => { - const light = getMobileThemeRuntimeVariables("t3-chat", "light", "ios"); - const dark = getMobileThemeRuntimeVariables("t3-code", "dark", "ios"); - const snapshot: SubscriptionUsageSnapshot = JSON.parse( - JSON.stringify({ - ...buildSubscriptionUsageSnapshot(presentations(), deepLink), - theme: createWidgetTheme("dark", { light, dark }), - }), - ); - const timeline = subscriptionUsageTimeline(snapshot, now); - expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]); - expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); - expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); - for (const entry of timeline) { - expect(JSON.parse(JSON.stringify(entry.props)).theme).toEqual({ - mode: "dark", - light: expect.objectContaining({ - background: light["--color-card"], - foreground: light["--color-foreground"], - }), - dark: expect.objectContaining({ - background: dark["--color-card"], - foreground: dark["--color-foreground"], - }), - }); - } - }); - it("keeps the default Android unfilled track distinguishable from the card", () => { - const theme = createWidgetTheme("system", { - light: getMobileThemeRuntimeVariables("t3-code", "light", "android"), - dark: getMobileThemeRuntimeVariables("t3-code", "dark", "android"), - }); - expect(contrastRatio(theme.dark.track, theme.dark.background)).toBeGreaterThanOrEqual(1.3); - expect(contrastRatio(theme.light.track, theme.light.background)).toBeGreaterThanOrEqual(1.3); - }); - it("expires providers independently without inventing a refill", () => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([ - provider(), - provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] }, - }), - ]), - deepLink, - ); - const timeline = subscriptionUsageTimeline(snapshot, now); - expect(timeline.map((entry) => entry.date.getTime())).toEqual([ - now, - now + 10 * 60_000, - now + 15 * 60_000, - ]); - expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); - expect(timeline[1]?.props.providers[1]?.windows[0]?.remaining).toBe(60); - expect(timeline[2]?.props.providers.every((provider) => provider.windows.length === 0)).toBe( - true, - ); - }); - it("marks a malformed check time immediately stale without storing null", () => { - const snapshot = buildSubscriptionUsageSnapshot( - presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]), - deepLink, - ); - expect(snapshot.checkedAt).toBe(0); - expect(snapshot.providers[0]).toMatchObject({ expiresAt: 0, windows: [] }); - expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1); - expect(JSON.stringify(snapshot)).not.toContain("null"); - }); it("uses the freshest copy of an account across environments before pooling", () => { const input = presentations(); input.set(EnvironmentId.make("other"), { @@ -329,21 +136,6 @@ describe("subscription widget snapshots", () => { }); describe("widget refresh probes", () => { - it("throttles each connected environment independently and retries failures", async () => { - const probe = vi - .fn<(id: string) => Promise>() - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValue(undefined); - const refresh = createWidgetRefresher(probe); - await refresh([], now); - expect(probe).not.toHaveBeenCalled(); - await refresh(["first"], now); - await refresh(["first", "second"], now + 1); - expect(probe.mock.calls).toEqual([["first"], ["second"]]); - await refresh(["first"], now + WIDGET_REFRESH_INTERVAL); - expect(probe.mock.calls).toEqual([["first"], ["second"], ["first"]]); - }); - it("does not overlap a slow probe even after the refresh interval", async () => { let finish!: () => void; const probe = vi.fn( diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 7fe24034f1e6..3f66714c6881 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -3,11 +3,8 @@ import "vite-plus/test/config"; const reactTests = [ "src/components/ui/Dialog.android.test.tsx", - "src/features/home/home-route-visibility.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", - "src/features/threads/use-visible-second-clock.test.tsx", - "src/features/threads/useThreadHeaderOptions.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", ]; @@ -23,7 +20,10 @@ export default defineConfig({ name: "unit", include: ["**/*.test.{ts,tsx,mjs}"], exclude: ["**/node_modules/**", ...reactTests], - setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + setupFiles: [ + "../../packages/shared/src/testing/longTempDir.ts", + "../../packages/shared/src/testing/providerEnv.ts", + ], }, }, { @@ -33,6 +33,7 @@ export default defineConfig({ include: reactTests, // Native components mock React Native and render through react-dom in these tests. environment: "jsdom", + setupFiles: ["../../packages/shared/src/testing/providerEnv.ts"], }, }, ], diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 0ec8b1c37b29..5064848571be 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - Measures real local HTTP/WebSocket sockets. import * as NodeHttp from "node:http"; +import * as NodeUtil from "node:util"; import { assert, it } from "@effect/vitest"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; @@ -218,143 +219,144 @@ const synchronized = (queue: Queue.Queue) => ), ); -it.live( - "reports V2 thread HTTP and WebSocket transfer budgets", - () => - Effect.gen(function* () { - const runs: TransferBudgetRun[] = []; - for (const provider of [ - ProviderDriverKind.make("codex"), - ProviderDriverKind.make("claudeAgent"), - ]) { - const counter = makeSqlStatementCounter(); - const run = yield* Effect.scoped( - Effect.gen(function* () { - const sink = yield* EventSink.EventSinkV2; - const projection = yield* ProjectionStore.ProjectionStoreV2; - yield* sink.write({ events: [threadCreated(provider)] }); - for (let index = 0; index < TRANSFER_HISTORY_TURN_COUNT; index++) - yield* sink.write({ events: turnEvents(provider, index, false) }); - const context = yield* Effect.context>(); - const server = yield* Layer.build( - HttpRouter.serve(layerRoutes, { disableListenLog: true }).pipe( - Layer.provide(Layer.succeedContext(context)), - Layer.provideMerge( - NodeHttpServer.layer(NodeHttp.createServer, { - host: "127.0.0.1", - port: 0, - websocket: { perMessageDeflate: true }, - }), - ), - ), - ); - const address = Context.get(server, HttpServer.HttpServer).address; - if (!("port" in address)) return yield* Effect.die("Expected TCP server"); - const base = `http://127.0.0.1:${address.port}`; - const threadSnapshot = yield* measureHttpGet({ - url: `${base}/api/orchestration/threads/${THREAD_ID}`, - headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, - }); - assert.equal( - threadSnapshot.status, - 200, - Buffer.from(threadSnapshot.decodedBody).toString(), - ); - assert.equal(threadSnapshot.contentEncoding, "gzip"); - const decodedThread = yield* decodeThreadSnapshot( - Buffer.from(threadSnapshot.decodedBody).toString(), - ); - assert.equal(decodedThread.projection.messages.length, TRANSFER_HISTORY_TURN_COUNT * 2); - assert.equal( - decodedThread.projection.turnItems.length, - TRANSFER_HISTORY_TURN_COUNT * 6, - ); - assert.notInclude(Buffer.from(threadSnapshot.decodedBody).toString(), "digest="); - const shellSnapshot = yield* measureHttpGet({ - url: `${base}/api/orchestration/shell`, - headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, - }); - assert.equal(shellSnapshot.status, 200); - const decodedShell = yield* decodeShellSnapshot( - Buffer.from(shellSnapshot.decodedBody).toString(), - ); - assert.equal(decodedShell.threads.length, 1); - const sequence = decodedThread.snapshotSequence; - assert.equal(decodedShell.snapshotSequence, sequence); - const url = `${base.replace("http:", "ws:")}/ws`; - const thread = yield* openMeasuredWsClient({ url, cookie: "" }); - const shell = yield* openMeasuredWsClient({ url, cookie: "" }); - const second = yield* openMeasuredWsClient({ url, cookie: "" }); - assert.include(thread.recorder.negotiatedExtensions(), "permessage-deflate"); - const tq = yield* subscribe(thread, "thread", sequence); - const sq = yield* subscribe(shell, "shell", sequence); - const ttq = yield* subscribe(second, "thread", sequence); - const ssq = yield* subscribe(second, "shell", sequence); - for (const queue of [tq, sq, ttq, ssq]) - assert.equal(yield* synchronized(queue), "replay"); - const threadBefore = thread.recorder.totals(); - const shellBefore = shell.recorder.totals(); - const secondBefore = second.recorder.totals(); - const sqlBefore = counter.count(); - const events = turnEvents(provider, TRANSFER_HISTORY_TURN_COUNT, true); - const stored = yield* sink.write({ events }); - const last = stored.at(-1)!; - const terminal = (item: StreamItem) => - "sequence" in item && item.sequence >= last.sequence; - for (const queue of [tq, sq, ttq, ssq]) yield* collectUntil(queue, terminal); - const measuredTurnWebSocket = difference(thread.recorder.totals(), threadBefore); - const measuredTurnShellWebSocket = difference(shell.recorder.totals(), shellBefore); - const measuredTurnSecondClientWebSocket = difference( - second.recorder.totals(), - secondBefore, - ); - const measuredTurnSqlStatements = counter.count() - sqlBefore; - const durable = yield* projection.getThreadProjection(THREAD_ID); - assert.isTrue( - durable.turnItems.some( - (item) => - item.type === "dynamic_tool" && JSON.stringify(item.output).length > 1_000_000, +it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => + Effect.gen(function* () { + const runs: TransferBudgetRun[] = []; + for (const provider of [ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ]) { + const counter = makeSqlStatementCounter(); + const run = yield* Effect.scoped( + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + const projection = yield* ProjectionStore.ProjectionStoreV2; + yield* sink.write({ events: [threadCreated(provider)] }); + for (let index = 0; index < TRANSFER_HISTORY_TURN_COUNT; index++) + yield* sink.write({ events: turnEvents(provider, index, false) }); + const context = yield* Effect.context>(); + const server = yield* Layer.build( + HttpRouter.serve(layerRoutes, { disableListenLog: true }).pipe( + Layer.provide(Layer.succeedContext(context)), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + websocket: { perMessageDeflate: true }, + }), ), - ); - yield* second.close; - const reconnectSqlStart = counter.count(); - const resumedThread = yield* openMeasuredWsClient({ url, cookie: "" }); - const resumedShell = yield* openMeasuredWsClient({ url, cookie: "" }); - const rq = yield* subscribe(resumedThread, "thread", sequence); - const rsq = yield* subscribe(resumedShell, "shell", sequence); - const threadMode = yield* synchronized(rq); - const shellMode = yield* synchronized(rsq); - const reconnectThread = { ...resumedThread.recorder.totals(), mode: threadMode }; - const reconnectShell = { ...resumedShell.recorder.totals(), mode: shellMode }; - assert.equal(threadMode, "snapshot"); - return { - provider, - threadSnapshot, - shellSnapshot, - measuredTurnWebSocket, - measuredTurnShellWebSocket, - measuredTurnSecondClientWebSocket, - measuredTurnSqlStatements, - reconnectThread, - reconnectShell, - reconnectSqlStatements: counter.count() - reconnectSqlStart, - } satisfies TransferBudgetRun; - }).pipe( - Effect.provide(Layer.fresh(layerServices).pipe(Layer.provideMerge(NodeServices.layer))), - Effect.withTracer(counter.tracer), - ), - ); - runs.push(run); - } - const fs = yield* FileSystem.FileSystem; - const report = formatTransferBudgetReport(runs); - for (const [path, contents] of [ - [process.env.T3CODE_TRANSFER_BUDGET_REPORT_PATH, report], - [process.env.T3CODE_TRANSFER_BUDGET_RESULT_PATH, formatTransferBudgetResult(runs)], - ]) - if (path && contents) yield* fs.writeFileString(path, contents); - yield* Effect.log(report); - assert.deepEqual(transferBudgetViolations(runs), []); - }).pipe(Effect.provide(NodeServices.layer)), - 30_000, + ), + ); + const address = Context.get(server, HttpServer.HttpServer).address; + if (!("port" in address)) return yield* Effect.die("Expected TCP server"); + const base = `http://127.0.0.1:${address.port}`; + const threadSnapshot = yield* measureHttpGet({ + url: `${base}/api/orchestration/threads/${THREAD_ID}`, + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, + }); + assert.equal( + threadSnapshot.status, + 200, + Buffer.from(threadSnapshot.decodedBody).toString(), + ); + assert.equal(threadSnapshot.contentEncoding, "gzip"); + const decodedThread = yield* decodeThreadSnapshot( + Buffer.from(threadSnapshot.decodedBody).toString(), + ); + assert.equal(decodedThread.projection.messages.length, TRANSFER_HISTORY_TURN_COUNT * 2); + assert.equal(decodedThread.projection.turnItems.length, TRANSFER_HISTORY_TURN_COUNT * 6); + assert.notInclude(Buffer.from(threadSnapshot.decodedBody).toString(), "digest="); + const shellSnapshot = yield* measureHttpGet({ + url: `${base}/api/orchestration/shell`, + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, + }); + assert.equal(shellSnapshot.status, 200); + const decodedShell = yield* decodeShellSnapshot( + Buffer.from(shellSnapshot.decodedBody).toString(), + ); + assert.equal(decodedShell.threads.length, 1); + const sequence = decodedThread.snapshotSequence; + assert.equal(decodedShell.snapshotSequence, sequence); + const url = `${base.replace("http:", "ws:")}/ws`; + const thread = yield* openMeasuredWsClient({ url, cookie: "" }); + const shell = yield* openMeasuredWsClient({ url, cookie: "" }); + const second = yield* openMeasuredWsClient({ url, cookie: "" }); + assert.include(thread.recorder.negotiatedExtensions(), "permessage-deflate"); + const tq = yield* subscribe(thread, "thread", sequence); + const sq = yield* subscribe(shell, "shell", sequence); + const ttq = yield* subscribe(second, "thread", sequence); + const ssq = yield* subscribe(second, "shell", sequence); + for (const queue of [tq, sq, ttq, ssq]) + assert.equal(yield* synchronized(queue), "replay"); + const threadBefore = thread.recorder.totals(); + const shellBefore = shell.recorder.totals(); + const secondBefore = second.recorder.totals(); + const sqlBefore = counter.count(); + const events = turnEvents(provider, TRANSFER_HISTORY_TURN_COUNT, true); + const stored = yield* sink.write({ events }); + const last = stored.at(-1)!; + const threadTerminal = (item: StreamItem) => + "sequence" in item && item.sequence >= last.sequence; + // Shell items carry the shell read when their batch is projected, and unchanged + // shells are skipped, so a turn split across batches may never yield an item at + // the last sequence. Wait for the final shell instead. + const finalShell = yield* projection.getThreadShell(THREAD_ID); + const shellTerminal = (item: StreamItem) => + item.kind === "thread.updated" && NodeUtil.isDeepStrictEqual(item.thread, finalShell); + for (const queue of [tq, ttq]) yield* collectUntil(queue, threadTerminal); + for (const queue of [sq, ssq]) yield* collectUntil(queue, shellTerminal); + const measuredTurnWebSocket = difference(thread.recorder.totals(), threadBefore); + const measuredTurnShellWebSocket = difference(shell.recorder.totals(), shellBefore); + const measuredTurnSecondClientWebSocket = difference( + second.recorder.totals(), + secondBefore, + ); + const measuredTurnSqlStatements = counter.count() - sqlBefore; + const durable = yield* projection.getThreadProjection(THREAD_ID); + assert.isTrue( + durable.turnItems.some( + (item) => + item.type === "dynamic_tool" && JSON.stringify(item.output).length > 1_000_000, + ), + ); + yield* second.close; + const reconnectSqlStart = counter.count(); + const resumedThread = yield* openMeasuredWsClient({ url, cookie: "" }); + const resumedShell = yield* openMeasuredWsClient({ url, cookie: "" }); + const rq = yield* subscribe(resumedThread, "thread", sequence); + const rsq = yield* subscribe(resumedShell, "shell", sequence); + const threadMode = yield* synchronized(rq); + const shellMode = yield* synchronized(rsq); + const reconnectThread = { ...resumedThread.recorder.totals(), mode: threadMode }; + const reconnectShell = { ...resumedShell.recorder.totals(), mode: shellMode }; + assert.equal(threadMode, "snapshot"); + return { + provider, + threadSnapshot, + shellSnapshot, + measuredTurnWebSocket, + measuredTurnShellWebSocket, + measuredTurnSecondClientWebSocket, + measuredTurnSqlStatements, + reconnectThread, + reconnectShell, + reconnectSqlStatements: counter.count() - reconnectSqlStart, + } satisfies TransferBudgetRun; + }).pipe( + Effect.provide(Layer.fresh(layerServices).pipe(Layer.provideMerge(NodeServices.layer))), + Effect.withTracer(counter.tracer), + ), + ); + runs.push(run); + } + const fs = yield* FileSystem.FileSystem; + const report = formatTransferBudgetReport(runs); + for (const [path, contents] of [ + [process.env.T3CODE_TRANSFER_BUDGET_REPORT_PATH, report], + [process.env.T3CODE_TRANSFER_BUDGET_RESULT_PATH, formatTransferBudgetResult(runs)], + ]) + if (path && contents) yield* fs.writeFileString(path, contents); + yield* Effect.log(report); + assert.deepEqual(transferBudgetViolations(runs), []); + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/package.json b/apps/server/package.json index ed0b97ada80b..c21519f17635 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c2d29e98047e..7a749b501bd3 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -46,9 +46,11 @@ const emitXAiAskUserQuestionThenHang = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; +const grokKillTaskShape = process.env.T3_ACP_GROK_KILL_TASK_SHAPE; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; +const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; @@ -461,6 +463,71 @@ function modelState(): AcpCompat.SessionModelState { }; } +function emitGrokKillTaskScenario( + sessionId: string, + promptId: string | undefined, + shape: string, +): void { + const taskId = "call-fb9d0000-0000-0000-0000-000000000026-29"; + const outcome = + shape === "already_exited" || shape === "known_failure" ? "already_exited" : "killed"; + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-target-command", + title: "run_terminal_command", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 40" }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-target-command", + status: "completed", + rawOutput: { + type: "BackgroundTaskStarted", + task_id: taskId, + task_type: "bash", + command: "sleep 40", + }, + }, + }); + if (shape === "known_failure") { + writeJsonRpcNotification("_x.ai/task_completed", { + sessionId, + update: { + sessionUpdate: "task_completed", + task_snapshot: { task_id: taskId, kind: "bash", exit_code: 7, output: "known failure\n" }, + }, + }); + } + writeJsonRpcNotification("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "kill-command", + title: `kill ${taskId} (${outcome})`, + kind: "other", + status: "completed", + rawInput: { + task_id: taskId, + ...(shape === "output_only" ? {} : { variant: "KillTask" }), + }, + ...(shape === "variant_only" ? {} : { rawOutput: { type: "KillTask", outcome } }), + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId, + promptId, + stopReason: "end_turn", + agentResult: null, + }); +} + const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; const resumeRelease = yield* Deferred.make(); @@ -1383,6 +1450,7 @@ const program = Effect.gen(function* () { (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) ) { const toolCallId = "tool-call-running-1"; + let runningCommandPublished: Promise = Promise.resolve(); if (runningCommandPidPath !== undefined) { const command = runningCommandIgnoresTerm ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\n" "$$" "$child" > "$1"; wait "$child"' @@ -1390,19 +1458,24 @@ const program = Effect.gen(function* () { if (runningCommandSeparateSession) { const launcher = [ 'const { spawn } = require("node:child_process");', - "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });", + "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: ['ignore', 'inherit', 'ignore'] });", "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));", ].join(" "); const detachedCommand = runningCommandIgnoresTerm - ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"' - : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'; + ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"' + : 'sleep 120 & child=$!; printf "%s %s %s\n" "$PPID" "$$" "$child" > "$1"; echo published; wait "$child"'; // Nested bash publishes "$PPID $$ $child" once it starts. Do not // write the launcher PID alone here: that races with bash and can - // clobber the triple that interrupt tests wait for. + // clobber the triple that interrupt tests wait for. It then prints a + // line, so an agent that exits after launch only exits once the + // triple exists instead of racing a slow launcher start. const detachedLauncher = NodeChildProcess.spawn( process.execPath, ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath], - { detached: true, stdio: "ignore" }, + { detached: true, stdio: ["ignore", "pipe", "ignore"] }, + ); + runningCommandPublished = new Promise((resolve) => + detachedLauncher.stdout.once("data", () => resolve()), ); detachedLauncher.unref(); } else { @@ -1460,8 +1533,9 @@ const program = Effect.gen(function* () { }, }); if (exitAfterRunningCommandLaunch) { - yield* Effect.sleep("100 millis"); - return yield* Effect.sync(() => process.exit(0)); + // The test sends `_test/exit` once T3 has recorded the published processes. + yield* Effect.promise(() => runningCommandPublished); + return yield* Effect.never; } while (!cancelledSessions.has(requestedSessionId)) { yield* Effect.sleep("25 millis"); @@ -1491,6 +1565,16 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (grokKillTaskShape !== undefined) { + emitGrokKillTaskScenario( + requestedSessionId, + promptIdFromRequestMeta(request), + grokKillTaskShape, + ); + // Keep the prompt RPC and peer alive after the kill result. + return yield* Effect.never; + } + if (emitPlanThenHang) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -1678,13 +1762,15 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (emitGrokBackgroundTaskStarted) { - const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + if (emitGrokBackgroundTaskStarted || emitGrokBackgroundTaskPoll) { + const toolCallId = !emitGrokBackgroundTaskPoll + ? "call-fb9d0000-0000-0000-0000-000000000026" + : "call-fb9d0000-0000-0000-0000-000000000026-29"; const command = "sleep 40; echo done-a"; writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, update: { - sessionUpdate: "tool_call", + sessionUpdate: !emitGrokBackgroundTaskPoll ? "tool_call" : "tool_call_update", toolCallId, title: "run_terminal_command", kind: "execute", @@ -1707,6 +1793,46 @@ const program = Effect.gen(function* () { }, }, }); + if (emitGrokBackgroundTaskPoll) { + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-background-poll", + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawOutput: { + type: "TaskOutput", + Result: { + task_id: toolCallId, + command, + status: "completed", + exit_code: 0, + output: "done-a", + }, + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "The background command finished." }, + }, + }); + writeJsonRpcNotification("_x.ai/session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "turn_completed", + prompt_id: promptIdFromRequestMeta(request), + stop_reason: "end_turn", + }, + }); + // Neither the prompt RPC nor the process ends, matching the Grok incident. + return yield* Effect.never; + } writeJsonRpcNotification("_x.ai/session/prompt_complete", { sessionId: requestedSessionId, promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", @@ -2441,8 +2567,14 @@ const program = Effect.gen(function* () { return Effect.succeed({}); }); - yield* agent.handleUnknownExtNotification((method) => - method === "_test/exit" ? Effect.sync(() => process.exit(19)) : Effect.void, + yield* agent.handleUnknownExtNotification((method, params) => + method === "_test/exit" + ? Effect.sync(() => { + const code = + typeof params === "object" && params !== null ? Reflect.get(params, "code") : undefined; + process.exit(typeof code === "number" ? code : 19); + }) + : Effect.void, ); return yield* Effect.never; diff --git a/apps/server/scripts/acp-replay-agent.ts b/apps/server/scripts/acp-replay-agent.ts index 351cfa131e22..950c6909d013 100644 --- a/apps/server/scripts/acp-replay-agent.ts +++ b/apps/server/scripts/acp-replay-agent.ts @@ -56,9 +56,12 @@ let nextAgentRequestId = 1; const pendingClientRequestIds = new Map(); const pendingAgentRequestMethods = new Map(); +// The test reads status while this agent may still be running; rename makes each +// write atomic so a reader never sees a truncated file. function writeStatus(failure?: unknown): void { + const temporaryStatusPath = `${replayStatusPath}.${process.pid}.tmp`; NodeFS.writeFileSync( - replayStatusPath, + temporaryStatusPath, JSON.stringify({ scenario: transcript.scenario, cursor, @@ -67,6 +70,7 @@ function writeStatus(failure?: unknown): void { }), "utf8", ); + NodeFS.renameSync(temporaryStatusPath, replayStatusPath); } function stableStringify(value: unknown): string { diff --git a/apps/server/scripts/acpMockCancellationState.test.ts b/apps/server/scripts/acpMockCancellationState.test.ts deleted file mode 100644 index 98ce4f9a1327..000000000000 --- a/apps/server/scripts/acpMockCancellationState.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { assert, it } from "@effect/vitest"; - -import { beginAcpMockPrompt } from "./acpMockCancellationState.ts"; - -it("does not carry an idle cancellation into the next prompt", () => { - const cancelledSessions = new Set(["session:cancelled-while-idle", "session:other"]); - - beginAcpMockPrompt(cancelledSessions, "session:cancelled-while-idle"); - - assert.isFalse(cancelledSessions.has("session:cancelled-while-idle")); - assert.isTrue(cancelledSessions.has("session:other")); -}); diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index d8bf059852b1..35d193b82db4 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -43,6 +43,13 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio ["fork-thread", "project-kept", "completed", forkPayload, "2026-08-02"], ["running-thread", "project-kept", "running", "{}", "2026-08-05"], ["settled-thread", "project-kept", "completed", '{"settledAt":"2026-08-01"}', "2026-08-05"], + [ + "archived-thread", + "project-kept", + "completed", + '{"archivedAt":"2026-08-01"}', + "2026-08-05", + ], [ "limit-thread", "project-kept", @@ -73,7 +80,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio VALUES ('session-shared', 'running-thread'), ('session-shared', 'stopped-thread')`; yield* sql`INSERT INTO orchestration_v2_projection_context_transfers (context_transfer_id, source_thread_id, target_thread_id, type, status, updated_at, payload_json) - VALUES ('transfer-1', 'settled-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; + VALUES ('transfer-1', 'archived-thread', 'stopped-thread', 'provider_handoff', 'completed', '2026-08-01', '{}')`; yield* sql`INSERT INTO scheduled_tasks (task_id, title, prompt, enabled, schedule_json, project_id, workspace_strategy_json, model_selection_json, runtime_mode, interaction_mode, created_by, creation_source, @@ -88,7 +95,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio }); it.layer(NodeServices.layer)("migrate-dev-db", (it) => { - it.effect("keeps stopped thread families from live projects and clears pending work", () => + it.effect("keeps stopped and settled thread families and clears pending work", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -97,7 +104,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { const source = yield* createFixtureSource(sourceDir); const result = yield* runMigrateDevDb( - { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, + { baseDir: destDir, source, projects: 5, threadsPerProject: 2 }, { sharedHome: sourceDir }, ); @@ -122,11 +129,11 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { ); assert.deepStrictEqual( kept.threads.map((row) => row.thread_id), - ["fork-thread", "stopped-thread"], + ["fork-thread", "settled-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.events.map((row) => row.stream_id), - ["fork-thread", "stopped-thread"], + ["fork-thread", "settled-thread", "stopped-thread"], ); assert.deepStrictEqual( kept.sessions.map((row) => row.provider_session_id), @@ -136,7 +143,7 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); - it.effect("never copies a settled thread's rows, and new events append after the source's", () => + it.effect("never copies an archived thread, and new events append after the source's", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slice-" }); @@ -160,21 +167,48 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { result.databasePath, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const [settledRows] = yield* sql<{ count: number }>` + const [archivedRows] = yield* sql<{ count: number }>` SELECT - (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'settled-thread') - + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'settled-thread') + (SELECT COUNT(*) FROM orchestration_v2_projection_runs WHERE thread_id = 'archived-thread') + + (SELECT COUNT(*) FROM orchestration_events WHERE stream_id = 'archived-thread') AS count`; const [sequence] = yield* sql<{ seq: number }>` SELECT seq FROM sqlite_sequence WHERE name = 'orchestration_events'`; - return { settledRows: settledRows?.count, sequence: sequence?.seq }; + return { archivedRows: archivedRows?.count, sequence: sequence?.seq }; }), ); - assert.equal(copied.settledRows, 0); + assert.equal(copied.archivedRows, 0); assert.equal(copied.sequence, sourceSequence?.seq); }), ); + it.effect("skips a thread family larger than the per-project limit", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-family-dest-" }); + const source = yield* createFixtureSource(sourceDir); + + // stopped-thread and its fork are 2 threads; settled-thread is 1. + const result = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 1 }, + { sharedHome: sourceDir }, + ); + const threads = yield* withDatabase( + result.databasePath, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ thread_id: string }>` + SELECT thread_id FROM orchestration_v2_projection_threads`; + }), + ); + assert.deepStrictEqual( + threads.map((row) => row.thread_id), + ["settled-thread"], + ); + }), + ); + it.effect("upgrades a source from before the V2 thread tables", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -194,33 +228,6 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); - it.effect("fails loudly on a migration slot collision", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-" }); - const destDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-slot-dest-" }); - const source = yield* createFixtureSource(sourceDir); - yield* withDatabase( - source, - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`UPDATE effect_sql_migrations - SET name = 'SomebodyElsesMigration' WHERE migration_id = 1`; - }), - ); - - const error = yield* runMigrateDevDb( - { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, - { sharedHome: sourceDir }, - ).pipe(Effect.flip); - assert.equal(error._tag, "MigrateDevDbSlotCollisionError"); - if (error._tag === "MigrateDevDbSlotCollisionError") { - assert.equal(error.slot, 1); - assert.equal(error.appliedName, "SomebodyElsesMigration"); - } - }), - ); - it.effect("refuses while a dev server holds the destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 71cd0ced16d8..2a559927176b 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -217,12 +217,11 @@ const CLEARED_TABLES: ReadonlyArray = [ const PROVIDER_SESSIONS_TABLE = "orchestration_v2_projection_provider_sessions"; +// Settled threads stay: they are most of real history, and the UI shows them. const rootIsVisible = (sql: SqlClient.SqlClient) => sql` root.deleted_at IS NULL AND json_extract(root.payload_json, '$.deletedAt') IS NULL - AND json_extract(root.payload_json, '$.archivedAt') IS NULL - AND json_extract(root.payload_json, '$.settledAt') IS NULL - AND json_extract(root.payload_json, '$.settledOverride') IS NOT 'settled'`; + AND json_extract(root.payload_json, '$.archivedAt') IS NULL`; /** * One transaction reads the source, so the copy is consistent while the real server writes. @@ -309,7 +308,7 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra FROM orchestration_v2_projection_threads`; yield* sql`CREATE TEMP TABLE stopped_families AS - SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at + SELECT f.root_id, root.project_id, MAX(f.updated_at) AS updated_at, COUNT(*) AS size FROM thread_families f JOIN orchestration_v2_projection_threads root ON root.thread_id = f.root_id WHERE ${rootIsVisible(sql)} @@ -341,6 +340,9 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra ) AS recency_rank FROM stopped_families sf JOIN kept_projects kp ON kp.project_id = sf.project_id + -- One orchestrator family can hold hundreds of subagents and most of + -- the database, so a family larger than the limit is skipped. + WHERE sf.size <= ${input.threadsPerProject} ) WHERE recency_rank <= ${input.threadsPerProject} )`; @@ -570,7 +572,7 @@ export const migrateDevDbCommand = Command.make( threadsPerProject: Flag.Int("threads-per-project").pipe( Flag.withDefault(10), Flag.withDescription( - "How many recent stopped threads, with their forks and subagents, to keep per project.", + "How many recent stopped threads, with their forks and subagents, to keep per project. A family with more threads than this is skipped.", ), ), baseDir: Flag.String("base-dir").pipe( diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index 64f1bcbae1b9..074dcfa596f2 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -26,55 +26,6 @@ const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(func }); it.layer(NodeServices.layer)("t3-sqlite-state", (it) => { - it.effect("reports each invalid SQL source with a specific error", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-input-" }); - - const multipleSources = yield* runSqliteState({ - operation: "query", - baseDir, - sql: "SELECT 1", - file: "fixture.sql", - }).pipe(Effect.flip); - assert.equal(multipleSources._tag, "SqliteStateMultipleSqlSourcesError"); - assert.equal(multipleSources.message, "Provide only one of --sql or --file."); - - const missingSource = yield* runSqliteState({ operation: "query", baseDir }).pipe( - Effect.flip, - ); - assert.equal(missingSource._tag, "SqliteStateMissingSqlSourceError"); - assert.equal(missingSource.message, "Provide one of --sql or --file."); - - const emptySql = yield* runSqliteState({ - operation: "query", - baseDir, - sql: " ", - }).pipe(Effect.flip); - assert.equal(emptySql._tag, "SqliteStateEmptySqlError"); - assert.equal(emptySql.message, "SQL input is empty."); - }), - ); - - it.effect("queries an isolated database through Effect SQL", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-query-" }); - yield* createFixtureDatabase(baseDir); - - const result = yield* runSqliteState({ - operation: "query", - baseDir, - sql: "SELECT id, label FROM fixtures", - }); - - assert.equal(result.operation, "query"); - if (result.operation === "query") { - assert.deepStrictEqual(result.rows, [{ id: 1, label: "existing" }]); - } - }), - ); - it.effect("enables checkpoint_fullfsync on its direct connection", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8157ef8d372b..a5d0a2f6616b 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -7,21 +7,17 @@ import { AssetPreviewTypeValidationError, ThreadId, TurnItemId, - type ToolActivityNativeAppReference, } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http"; import { vi } from "vite-plus/test"; @@ -34,7 +30,6 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; -import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; import { githubMediaResponse } from "./GitHubMediaFetch.ts"; @@ -168,6 +163,7 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + // regression: #17026 apply host color scheme to saved MCP apps it.effect("preserves filesystem failures instead of reporting oversized HTML", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -291,30 +287,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("reports pixel dimensions from an image header and nothing for other files", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" }); - const png = Uint8Array.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, - 0x06, 0x40, 0, 0, 0x03, 0x84, - ]); - yield* fs.writeFile(path.join(root, "shot.png"), png); - yield* fs.writeFileString(path.join(root, "clip.mp4"), "video"); - yield* fs.writeFileString(path.join(root, "broken.png"), "not a png"); - const issue = (name: string) => - issueAssetUrl({ - resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name }, - workspaceRoot: root, - }); - - expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 }); - expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined(); - expect((yield* issue("broken.png")).imageDimensions).toBeUndefined(); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("resolves relative media paths from the thread workspace, including outside it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -387,41 +359,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect.each([ - { form: "absolute", requestedPath: undefined, checked: ["absolute.mp4"] }, - { - form: "workspace-relative", - requestedPath: "clips/relative.mp4", - checked: ["workspace", "clips", "relative.mp4"], - }, - { - form: "home-relative", - requestedPath: "~/Downloads/home.mp4", - checked: ["home", "Downloads", "home.mp4"], - }, - ])("reports the absolute path it checked for a missing $form media path", (scenario) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-missing-" }); - const root = path.join(directory, "workspace"); - yield* fs.makeDirectory(root); - const checkedPath = path.join(directory, ...scenario.checked); - const error = yield* issueAssetUrl({ - resource: { - _tag: "media-file", - threadId: ThreadId.make("thread-1"), - path: scenario.requestedPath ?? checkedPath, - }, - workspaceRoot: root, - }).pipe( - Effect.flip, - Effect.provideService(HostProcess.HomeDirectory, path.join(directory, "home")), - ); - expect(error).toMatchObject({ _tag: "AssetWorkspaceAssetNotFoundError", checkedPath }); - }).pipe(Effect.provide(layerTest)), - ); - it.effect.skipIf(!symlinksSupported)( "rejects non-previewable files, disguised targets, and directories", () => @@ -567,40 +504,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect.skipIf(!symlinksSupported)( - "closes a descriptor rejected when its path changes during open", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); - const filePath = path.join(root, "recording.mp4"); - const secretPath = path.join(root, "secret.txt"); - yield* fs.writeFileString(filePath, "video"); - yield* fs.writeFileString(secretPath, "secret"); - const canonicalPath = yield* fs.realPath(filePath); - const originalOpen = (yield* Effect.promise(() => - vi.importActual("node:fs/promises"), - )).open; - let opened: NodeFSP.FileHandle | undefined; - const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { - const handle = await originalOpen(target, flags, mode); - if (target === canonicalPath) { - opened = handle; - await NodeFSP.unlink(filePath); - await NodeFSP.symlink(secretPath, filePath); - } - return handle; - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => openSpy.mockImplementation(originalOpen)), - ); - expect(yield* openMediaFile(canonicalPath)).toBeNull(); - expect(opened).toBeDefined(); - expect(opened?.fd).toBe(-1); - }).pipe(Effect.provide(layerTest)), - ); - it.effect.skipIf(!symlinksSupported)( "rejects an ancestor symlink race even when canonical path rechecks would pass", () => @@ -782,204 +685,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("issues draft workspace URLs without a thread", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-", - }); - const htmlPath = path.join(root, "report.html"); - const cssPath = path.join(root, "report.css"); - yield* fileSystem.writeFileString(htmlPath, ''); - yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); - const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); - const canonicalCssPath = yield* fileSystem.realPath(cssPath); - - const result = yield* issueAssetUrl({ - resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" }, - workspaceRoot: root, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "report.html")).toEqual({ - kind: "file", - path: canonicalHtmlPath, - }); - expect(yield* resolveAsset(token, "report.css")).toEqual({ - kind: "file", - path: canonicalCssPath, - }); - expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serves home-relative draft media files from the home folder", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-root-", - }); - const home = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-home-", - }); - const clipPath = path.join(home, "Downloads", "clip.mp4"); - yield* fileSystem.makeDirectory(path.dirname(clipPath), { recursive: true }); - yield* fileSystem.writeFileString(clipPath, "video"); - const canonicalClipPath = yield* fileSystem.realPath(clipPath); - - const result = yield* issueAssetUrl({ - resource: { _tag: "draft-workspace-file", cwd: root, path: "~/Downloads/clip.mp4" }, - workspaceRoot: root, - }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const token = suffix.slice(0, suffix.indexOf("/")); - - expect(yield* resolveAsset(token, "clip.mp4")).toMatchObject({ - kind: "file", - path: canonicalClipPath, - mimeType: "video/mp4", - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serves absolute draft media files exactly, wherever they live", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-root-", - }); - const outside = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-outside-", - }); - const clipPath = path.join(outside, "clip.mp4"); - yield* fileSystem.writeFileString(clipPath, "video"); - const canonicalClipPath = yield* fileSystem.realPath(clipPath); - - const result = yield* issueAssetUrl({ - resource: { _tag: "draft-workspace-file", cwd: root, path: clipPath }, - workspaceRoot: root, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "clip.mp4")).toMatchObject({ - kind: "file", - path: canonicalClipPath, - mimeType: "video/mp4", - }); - expect(yield* resolveAsset(token, "other.mp4")).toBeNull(); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("falls back to the resource cwd for relative draft paths", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-draft-fallback-", - }); - const htmlPath = path.join(root, "report.html"); - yield* fileSystem.writeFileString(htmlPath, "

draft

"); - const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); - - const result = yield* issueAssetUrl({ - resource: { _tag: "draft-workspace-file", cwd: root, path: "report.html" }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "report.html")).toEqual({ - kind: "file", - path: canonicalHtmlPath, - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves non-missing canonical path failures when issuing asset URLs", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-permission-root-", - }); - const htmlPath = path.join(root, "report.html"); - yield* fileSystem.writeFileString(htmlPath, "

report

"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "realPath", - pathOrDescriptor: htmlPath, - }); - const failingFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - realPath: () => Effect.fail(cause), - }); - - const error = yield* issueAssetUrl({ - resource: { - _tag: "workspace-file", - threadId: ThreadId.make("thread-1"), - path: htmlPath, - }, - workspaceRoot: root, - }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem), Effect.flip); - - expect(error.message).toBe("Failed to inspect the workspace asset."); - expect(error).toMatchObject({ - _tag: "AssetWorkspaceAssetInspectionError", - resource: { - _tag: "workspace-file", - threadId: "thread-1", - path: htmlPath, - }, - }); - expect(error.cause).toBe(cause); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("issues exact workspace URLs for image previews", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-image-workspace-", - }); - const assetsDirectory = path.join(root, "assets"); - const imagePath = path.join(assetsDirectory, "icon.png"); - const siblingPath = path.join(assetsDirectory, "other.png"); - yield* fileSystem.makeDirectory(assetsDirectory, { recursive: true }); - yield* fileSystem.writeFile(imagePath, new Uint8Array([137, 80, 78, 71])); - yield* fileSystem.writeFile(siblingPath, new Uint8Array([137, 80, 78, 71])); - const canonicalImagePath = yield* fileSystem.realPath(imagePath); - - const result = yield* issueAssetUrl({ - resource: { - _tag: "workspace-file", - threadId: ThreadId.make("thread-1"), - path: imagePath, - }, - workspaceRoot: root, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "icon.png")).toEqual({ - kind: "file", - path: canonicalImagePath, - }); - expect(yield* resolveAsset(token, "other.png")).toBeNull(); - expect(yield* resolveAsset(token, "../icon.png")).toBeNull(); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("previews workspace files with literal fragment characters in their paths", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1065,188 +770,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("issues exact attachment capabilities by attachment id", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`); - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); - yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); - - const result = yield* issueAssetUrl({ - resource: { _tag: "attachment", attachmentId }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); - - expect(yield* resolveAsset(token, "ignored.png")).toEqual({ - kind: "file", - path: attachmentPath, - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("signs the same HTML page URL within an expiry window", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-html"; - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); - yield* fileSystem.writeFileString( - path.join(config.attachmentsDir, `${attachmentId}.html`), - "

render

", - ); - const bucketMs = 30 * 60 * 1000; - const issue = issueAssetUrl({ - resource: { _tag: "attachment", attachmentId, disposition: "inline" }, - }); - yield* TestClock.setTime(bucketMs + 1); - const first = yield* issue; - yield* TestClock.adjust("20 minutes"); - const second = yield* issue; - yield* TestClock.adjust("10 minutes"); - const next = yield* issue; - - expect(second.relativeUrl).toBe(first.relativeUrl); - expect(first.expiresAt).toBe(4 * bucketMs); - expect(next.expiresAt).toBe(5 * bucketMs); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serves video attachments inline", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`); - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); - yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); - - const result = yield* issueAssetUrl({ - resource: { - _tag: "attachment", - attachmentId, - fileName: "demo.mp4", - mimeType: 'video/mp4; codecs="avc1.42E01E"', - }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - - expect( - yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), - ).toEqual({ - kind: "file", - path: attachmentPath, - fileName: "demo.mp4", - mimeType: "video/mp4", - }); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("issues signed native application icon capabilities", () => { - const resolvedApps: Array = []; - return Effect.gen(function* () { - const app = { _tag: "app-id", appId: "com.example.Editor" } as const; - const result = yield* issueAssetUrl({ resource: { _tag: "native-app-icon", app } }); - - expect(result.relativeUrl).toMatch( - new RegExp(`^${ASSET_ROUTE_PREFIX}/[^/]+/native-app-icon\\.png$`, "u"), - ); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - const token = suffix.slice(0, separator); - const name = suffix.slice(separator + 1); - expect(yield* resolveAsset(token, name)).toEqual({ kind: "file", path: "/icons/editor.png" }); - expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); - expect(resolvedApps).toEqual([app]); - }).pipe( - Effect.provide( - Layer.merge( - layerTest, - Layer.succeed(NativeAppIconResolver.NativeAppIconResolver, { - resolve: (app) => - Effect.sync(() => { - resolvedApps.push(app); - return "/icons/editor.png"; - }), - }), - ), - ), - ); - }); - - it.effect("serves document attachments inline when a viewer requests it", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-pdf"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`); - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); - yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); - - const result = yield* issueAssetUrl({ - resource: { - _tag: "attachment", - attachmentId, - fileName: "report.pdf", - mimeType: "application/pdf", - disposition: "inline", - }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - - expect( - yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), - ).toEqual({ - kind: "file", - path: attachmentPath, - fileName: "report.pdf", - mimeType: "application/pdf", - }); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serves audio previews with their stored format and keeps saving explicit", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000003-wav"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.wav`); - yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); - yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); - for (const disposition of ["inline", "attachment"] as const) { - const result = yield* issueAssetUrl({ - resource: { - _tag: "attachment", - attachmentId, - fileName: "recording.wav", - mimeType: "application/octet-stream", - disposition, - }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - expect( - yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), - ).toEqual({ - kind: "file", - path: attachmentPath, - fileName: "recording.wav", - mimeType: disposition === "inline" ? "audio/wav" : "application/octet-stream", - ...(disposition === "attachment" ? { download: true } : {}), - }); - } - }).pipe(Effect.provide(layerTest)), - ); - it.effect("keeps inline requests for other attachment types as downloads", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1332,27 +855,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("issues project favicon capabilities for a saved override", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-override-", - }); - yield* fileSystem.makeDirectory(path.join(root, "brand")); - yield* fileSystem.writeFileString(path.join(root, "brand", "custom.svg"), ""); - yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "auto"); - - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - projectFaviconPath: "brand/custom.svg", - }); - - expect(result.sourcePath).toBe(path.join("brand", "custom.svg")); - expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.svg$/); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("issues an exact capability for a saved favicon outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1412,135 +914,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("keeps automatic favicon resolution separate from a saved override", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-automatic-", - }); - yield* fileSystem.makeDirectory(path.join(root, "brand")); - yield* fileSystem.writeFileString(path.join(root, "brand", "saved.svg"), "saved"); - yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "automatic"); - - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }); - - expect(result.sourcePath).toBe("favicon.svg"); - expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("serves a native macOS app icon as its embedded PNG", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-icns-", - }); - const icns = new Uint8Array(8 + 8 + screenshotPng.length); - icns.set(new TextEncoder().encode("icns")); - new DataView(icns.buffer).setUint32(4, icns.length); - icns.set(new TextEncoder().encode("ic07"), 8); - new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length); - icns.set(screenshotPng, 16); - yield* fileSystem.makeDirectory(path.join(root, "Resources")); - yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns); - - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separatorIndex = suffix.indexOf("/"); - - expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns")); - const asset = yield* resolveAsset( - suffix.slice(0, separatorIndex), - suffix.slice(separatorIndex + 1), - ); - expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png"); - expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("rejects a resolved project favicon with a non-image extension", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-type-", - }); - yield* fileSystem.writeFileString(path.join(root, "secret.txt"), "not an image"); - - const error = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - projectFaviconPath: "secret.txt", - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("buckets project favicon expiry after content hashing", () => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-expiry-", - }); - yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); - - const bucketMs = 30 * 60 * 1000; - yield* TestClock.setTime(bucketMs - 1); - const crossingCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (algorithm, data) => - TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), - }); - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); - - expect(result.expiresAt).toBe(3 * bucketMs); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("preserves structured project favicon resolution causes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-error-", - }); - const platformCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - }); - const resolutionCause = new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot: root, - relativePath: "favicon.svg", - cause: platformCause, - }); - const resolver = ProjectFaviconResolver.ProjectFaviconResolver.of({ - resolvePath: () => Effect.fail(resolutionCause), - }); - - const error = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }).pipe( - Effect.provideService(ProjectFaviconResolver.ProjectFaviconResolver, resolver), - Effect.flip, - ); - - expect(error.message).toBe("Failed to resolve project favicon."); - expect(error._tag).toBe("AssetProjectFaviconResolutionError"); - expect(error.cause).toBe(resolutionCause); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("serves GitHub-hosted pull request media through the repository's credential", () => Effect.gen(function* () { const resolve = (relativeUrl: string) => { diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 251b15f9ece4..c25dce2494f2 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -493,7 +493,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i readonly resource: AssetResource; readonly workspaceRoot?: string; readonly projectFaviconPath?: string; - readonly projectCheckoutPending?: boolean; + /** Where a cloned project's checkout stands, while the clone tracker still knows it. */ + readonly projectCheckout?: "pending" | "cloned"; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -648,19 +649,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ); const faviconResolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; // A lookup in a half-cloned checkout would cache a miss that outlives the clone. - const faviconPath = input.projectCheckoutPending - ? null - : yield* faviconResolver - .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) - .pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconResolutionError({ - resource: input.resource, - cause, - }), - ), - ); + // Once it lands, a miss cached before (by enrichment) is stale too. + if (input.projectCheckout === "cloned") yield* faviconResolver.invalidate(workspaceRoot); + const faviconPath = + input.projectCheckout === "pending" + ? null + : yield* faviconResolver + .resolvePath(workspaceRoot, input.projectFaviconPath ?? undefined) + .pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconResolutionError({ + resource: input.resource, + cause, + }), + ), + ); const isExternalOverride = faviconPath !== null && input.projectFaviconPath !== undefined && diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index 1bc20d9f68eb..1eb60a1544b2 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -10,12 +10,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { base64UrlEncode, signPayload } from "../auth/utils.ts"; import * as ServerConfig from "../config.ts"; -import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, deletePendingAttachment, @@ -49,22 +47,6 @@ const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect( ); describe("AttachmentUpload", () => { - it.effect("signs the attachment metadata and validates the upload token", () => - Effect.gen(function* () { - const issued = yield* issueAttachmentUploadUrl(uploadInput); - expect(parseThreadSegmentFromAttachmentId(issued.attachmentId)).toBe("pending"); - - const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); - expect(yield* validateAttachmentUploadToken(token)).toMatchObject({ - kind: "attachment-upload", - attachmentId: issued.attachmentId, - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 6, - }); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("rejects tampered and malformed upload tokens", () => Effect.gen(function* () { const issued = yield* issueAttachmentUploadUrl(uploadInput); @@ -102,31 +84,6 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("rejects expired upload tokens", () => - Effect.gen(function* () { - const issued = yield* issueAttachmentUploadUrl(uploadInput); - const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); - - yield* TestClock.adjust("11 minutes"); - expect(yield* validateAttachmentUploadToken(token)).toBeNull(); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("removes expired pending uploads while issuing a new upload URL", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const staleId = "pending-00000000-0000-4000-8000-0000000000cc"; - const stalePath = NodePath.join(config.attachmentsDir, `${staleId}.png`); - NodeFS.writeFileSync(stalePath, Buffer.from("pixels")); - NodeFS.utimesSync(stalePath, 0, 0); - - yield* TestClock.adjust("25 hours"); - yield* issueAttachmentUploadUrl(uploadInput); - - expect(NodeFS.existsSync(stalePath)).toBe(false); - }).pipe(Effect.provide(layerTest)), - ); - it.effect("stores the expected bytes without leaving temporary files", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/NativeAppIconResolver.test.ts b/apps/server/src/assets/NativeAppIconResolver.test.ts deleted file mode 100644 index 6c69eebceafc..000000000000 --- a/apps/server/src/assets/NativeAppIconResolver.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -import * as ServerConfig from "../config.ts"; -import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; - -function emptyProcessHandle() { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - -describe("resolveNativeAppIcon", () => { - it.effect("escapes Spotlight wildcards and caches misses", () => { - const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.sync(() => { - const input = command as unknown as { - readonly command: string; - readonly args: ReadonlyArray; - }; - commands.push(input); - return emptyProcessHandle(); - }), - ); - const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-native-app-icon-test-", - }); - const layerDependencies = Layer.mergeAll( - layerConfig, - Layer.succeed(HostProcess.Platform, "darwin"), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - ).pipe(Layer.provideMerge(NodeServices.layer)); - const layerTest = NativeAppIconResolver.layer.pipe(Layer.provide(layerDependencies)); - const app = { _tag: "display-name", displayName: "Review * App" } as const; - - return Effect.gen(function* () { - const resolver = yield* NativeAppIconResolver.NativeAppIconResolver; - expect(yield* resolver.resolve(app)).toBeNull(); - expect(yield* resolver.resolve(app)).toBeNull(); - - expect(commands).toHaveLength(1); - expect(commands[0]).toMatchObject({ command: "/usr/bin/mdfind" }); - expect(commands[0]?.args[0]).toContain("Review \\* App"); - - for (let index = 0; index < 256; index += 1) { - expect( - yield* resolver.resolve({ - _tag: "display-name", - displayName: `Missing Review App ${index}`, - }), - ).toBeNull(); - } - expect(commands).toHaveLength(257); - expect(yield* resolver.resolve(app)).toBeNull(); - expect(commands).toHaveLength(258); - }).pipe(Effect.provide(layerTest)); - }); -}); diff --git a/apps/server/src/assets/icns.test.ts b/apps/server/src/assets/icns.test.ts index ee129b30032e..629587f75000 100644 --- a/apps/server/src/assets/icns.test.ts +++ b/apps/server/src/assets/icns.test.ts @@ -42,17 +42,6 @@ describe("extractIcnsPng", () => { expect(widthOf(extractIcnsPng(file, 96))).toBe(128); }); - it("falls back to the largest PNG when none covers the requested size", () => { - const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]); - - expect(widthOf(extractIcnsPng(file, 96))).toBe(32); - }); - - it("returns null for files without a PNG entry or with a foreign header", () => { - expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull(); - expect(extractIcnsPng(pngHeader(128), 96)).toBeNull(); - }); - it("stops at an entry whose length runs past the file", () => { const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]); new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096); diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 5b39d779c69f..2c416bf70acb 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,16 +6,13 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { - attachmentFileExtension, chatAttachmentStableKey, createAttachmentId, createDeterministicAttachmentId, createPendingAttachmentId, parseAttachmentUuid, - parseAttachmentFileExtension, planAttachmentClaim, parseThreadSegmentFromAttachmentId, - resolveAttachmentPathById, sweepStalePendingAttachments, threadHtmlRenderAttachmentIds, } from "./attachmentStore.ts"; @@ -62,21 +59,6 @@ describe("attachmentStore", () => { expect(dashed && parseThreadSegmentFromAttachmentId(dashed)).toBe("thread-a"); }); - it("sanitizes thread ids when creating attachment ids", () => { - const attachmentId = createAttachmentId("thread.folder/unsafe space"); - expect(attachmentId).toBeTruthy(); - if (!attachmentId) { - return; - } - - const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - expect(threadSegment).toBeTruthy(); - expect(threadSegment).toMatch(/^[a-z0-9_-]+$/i); - expect(threadSegment).not.toContain("."); - expect(threadSegment).not.toContain("%"); - expect(threadSegment).not.toContain("/"); - }); - it("parses exact thread segments from attachment ids without prefix collisions", () => { const fooId = "foo-00000000-0000-4000-8000-000000000001"; const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002"; @@ -85,15 +67,6 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar"); }); - it("normalizes created thread segments to lowercase", () => { - const attachmentId = createAttachmentId("Thread.Foo"); - expect(attachmentId).toBeTruthy(); - if (!attachmentId) { - return; - } - expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); - }); - it("reserves the pending attachment segment", () => { const pendingId = createPendingAttachmentId(); expect(parseThreadSegmentFromAttachmentId(pendingId)).toBe("pending"); @@ -104,68 +77,6 @@ describe("attachmentStore", () => { ); }); - it("preserves safe file extensions in attachment ids and paths", () => { - const attachmentId = createPendingAttachmentId(".PDF"); - - expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending"); - expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/); - expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf"); - expect(attachmentFileExtension("report.PDF")).toBe(".pdf"); - expect(attachmentFileExtension("report")).toBe(".bin"); - expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin"); - expect(attachmentFileExtension("archive.part")).toBe(".bin"); - expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128); - }); - - it("resolves attachment path by id using the extension that exists on disk", () => { - const attachmentsDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), - ); - try { - const attachmentId = "thread-1-attachment"; - const pngPath = NodePath.join(attachmentsDir, `${attachmentId}.png`); - NodeFS.writeFileSync(pngPath, Buffer.from("hello")); - - const resolved = resolveAttachmentPathById({ - attachmentsDir, - attachmentId, - }); - expect(resolved).toBe(pngPath); - } finally { - NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); - } - }); - - it("returns null when no attachment file exists for the id", () => { - const attachmentsDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), - ); - try { - const resolved = resolveAttachmentPathById({ - attachmentsDir, - attachmentId: "thread-1-missing", - }); - expect(resolved).toBeNull(); - } finally { - NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); - } - }); - - it("resolves generic attachments without scanning the attachment directory", () => { - const attachmentsDir = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"), - ); - try { - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip"; - const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`); - NodeFS.writeFileSync(archivePath, Buffer.from("archive")); - - expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath); - } finally { - NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); - } - }); - it("plans pending attachment claims with direct filename lookups", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 4ec8eff2bfef..aa702b6bfbee 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -4,22 +4,16 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as PersistenceErrors from "../persistence/Errors.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; const TEST_SERVER_PORT = 13_773; -const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError); -const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError); const layerServerConfig = (overrides?: Partial) => Layer.effect( @@ -261,86 +255,6 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ), ); - it.effect("keeps the pairing issue error as the immediate recovery failure", () => - Effect.gen(function* () { - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sessions = yield* SessionStore.SessionStore; - const sql = yield* SqlClient.SqlClient; - const token = "reusable-dev-auth-token-that-is-long-enough"; - const seeded = yield* sessions.verify(token); - - yield* sessions.revoke(seeded.sessionId); - yield* sql` - CREATE TRIGGER reject_startup_pairing_link - BEFORE INSERT ON auth_pairing_links - BEGIN - SELECT RAISE(ABORT, 'startup pairing insert rejected'); - END - `; - - const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential()); - - expect(error._tag).toBe("ServerAuthPairingLinkCreationError"); - expect(isPairingCredentialIssueError(error.cause)).toBe(true); - if (isPairingCredentialIssueError(error.cause)) { - expect(isPersistenceSqlError(error.cause.cause)).toBe(true); - } - }).pipe( - Effect.provide( - layerEnvironmentAuth({ - mode: "web", - devUrl: new URL("http://127.0.0.1:5173"), - devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), - }), - ), - ), - ); - - it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => - Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.UnknownBootstrapCredentialError({}), - ); - - expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - }), - ); - - it.effect("maps unexpected bootstrap failures to 500", () => - Effect.sync(() => { - const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ - cause: new Error("sqlite is unavailable"), - }); - const error = EnvironmentAuth.toBootstrapExchangeError(cause); - - expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); - expect(error.message).toBe("Failed to validate bootstrap credential."); - if (error._tag === "ServerAuthBootstrapCredentialValidationError") { - expect(error.cause).toBe(cause); - } - }), - ); - - it.effect("issues standard pairing credentials by default", () => - Effect.gen(function* () { - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sessions = yield* SessionStore.SessionStore; - - const pairingCredential = yield* serverAuth.issuePairingCredential(); - const exchanged = yield* serverAuth.createBrowserSession( - pairingCredential.credential, - requestMetadata, - ); - const verified = yield* serverAuth.authenticateHttpRequest( - makeCookieRequest(sessions.cookieName, exchanged.sessionToken), - ); - - expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual(AuthStandardClientScopes); - expect(verified.subject).toBe("one-time-token"); - }).pipe(Effect.provide(layerEnvironmentAuth())), - ); - it.effect("prefers a bearer token over a stale legacy cookie", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -508,20 +422,6 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ), ); - it.effect("keeps user-issued administrative pairing links manageable", () => - Effect.gen(function* () { - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const pairingCredential = yield* serverAuth.issuePairingCredential({ - scopes: AuthStandardClientScopes, - }); - const listedPairingLinks = yield* serverAuth.listPairingLinks(); - - expect( - listedPairingLinks.find((pairingLink) => pairingLink.id === pairingCredential.id)?.subject, - ).toBe("one-time-token"); - }).pipe(Effect.provide(layerEnvironmentAuth())), - ); - it.effect("issues startup pairing URLs that bootstrap administrative sessions", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 5f4502a6f24e..2d74c81b8cb9 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -560,7 +560,11 @@ export class EnvironmentAuth extends Context.Service< readonly access: AuthMcpClientAccess; readonly client: AuthClientMetadata; }) => Effect.Effect< - { readonly token: string; readonly expiresAt: DateTime.DateTime }, + { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, ServerAuthInternalError >; /** @@ -602,7 +606,7 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -export function toBootstrapExchangeError( +function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { @@ -1295,7 +1299,11 @@ export const make = Effect.gen(function* () { client: { ...input.client, label: input.label, deviceType: "bot" }, }) .pipe( - Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })), + Effect.map((issued) => ({ + sessionId: issued.sessionId, + token: issued.token, + expiresAt: issued.expiresAt, + })), Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 7e1ead393a8a..9801e069c2c8 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -93,21 +93,4 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = expect(listedAfterRevoke).toHaveLength(0); }).pipe(Effect.provide(layerEnvironmentAuth())), ); - - it.effect("surfaces lastConnectedAt through the listed session view", () => - Effect.gen(function* () { - const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sessionCredentials = yield* SessionStore.SessionStore; - - const issued = yield* environmentAuth.issueSession({ - label: "remote-ipad", - }); - const beforeConnect = yield* environmentAuth.listSessions(); - yield* sessionCredentials.markConnected(issued.sessionId); - const afterConnect = yield* environmentAuth.listSessions(); - - expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); - expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); - }).pipe(Effect.provide(layerEnvironmentAuth())), - ); }); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 0244df8ac137..06189cc53331 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -27,25 +27,6 @@ const layerEnvironmentAuthPolicy = (overrides?: Partial { - it.effect("uses desktop-managed-local policy for desktop mode", () => - Effect.gen(function* () { - const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; - const descriptor = yield* policy.getDescriptor(); - - expect(descriptor.policy).toBe("desktop-managed-local"); - expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); - expect(descriptor.serverUpdateScope).toBe("environment:maintain"); - expect(descriptor.sessionCookieName).toBe("t3_session_3773"); - }).pipe( - Effect.provide( - layerEnvironmentAuthPolicy({ - mode: "desktop", - port: 3773, - }), - ), - ), - ); - it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () => Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; @@ -98,58 +79,4 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { ), ), ); - - it.effect("uses remote-reachable policy for wildcard web hosts", () => - Effect.gen(function* () { - const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; - const descriptor = yield* policy.getDescriptor(); - - expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); - }).pipe( - Effect.provide( - layerEnvironmentAuthPolicy({ - mode: "web", - host: "0.0.0.0", - }), - ), - ), - ); - - it.effect("isolates wildcard-bound web development sessions", () => - Effect.gen(function* () { - const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; - const descriptor = yield* policy.getDescriptor(); - - expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); - }).pipe( - Effect.provide( - layerEnvironmentAuthPolicy({ - mode: "web", - host: "0.0.0.0", - port: 5775, - devUrl: new URL("http://127.0.0.1:5736"), - }), - ), - ), - ); - - it.effect("uses remote-reachable policy for non-loopback web hosts", () => - Effect.gen(function* () { - const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; - const descriptor = yield* policy.getDescriptor(); - - expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); - }).pipe( - Effect.provide( - layerEnvironmentAuthPolicy({ - mode: "web", - host: "192.168.1.50", - }), - ), - ), - ); }); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 1047f43489d9..561633d3524b 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -13,8 +13,6 @@ import { } from "@t3tools/shared/desktopBootstrapToken"; import * as ServerConfig from "../config.ts"; -import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; -import { PersistenceSqlError } from "../persistence/Errors.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; @@ -46,36 +44,7 @@ const layerPairingGrantStore = ( Layer.provide(layerServerConfig(overrides)), ); -const layerPairingGrantStoreTest = ( - overrides: Partial, -) => - Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe( - Layer.provide( - Layer.succeed( - AuthPairingLinks.AuthPairingLinkRepository, - AuthPairingLinks.AuthPairingLinkRepository.of({ - create: () => Effect.void, - consumeAvailable: () => Effect.succeedNone, - listActive: () => Effect.succeed([]), - revoke: () => Effect.succeed(false), - getByCredential: () => Effect.succeedNone, - ...overrides, - }), - ), - ), - Layer.provide(layerServerConfig()), - ); - it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { - it.effect("issues pairing tokens in a short manual-entry format", () => - Effect.gen(function* () { - const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; - const issued = yield* bootstrapCredentials.issueOneTimeToken(); - - expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); - }).pipe(Effect.provide(layerPairingGrantStore())), - ); - it.effect("issues one-time bootstrap tokens that can only be consumed once", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -155,27 +124,6 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(layerPairingGrantStore())), ); - it.effect("seeds the desktop bootstrap credential as a reusable grant", () => - Effect.gen(function* () { - const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; - const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); - const second = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); - const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); - - expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual(AuthStandardClientScopes); - expect(first.subject).toBe("desktop-bootstrap"); - expect(second.method).toBe("desktop-bootstrap"); - expect(third.method).toBe("desktop-bootstrap"); - }).pipe( - Effect.provide( - layerPairingGrantStore({ - desktopBootstrapToken: "desktop-bootstrap-token", - }), - ), - ), - ); - it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -259,55 +207,4 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { } }).pipe(Effect.scoped, Effect.provide(layerPairingGrantStore())), ); - - it.effect("lists and revokes active pairing links", () => - Effect.gen(function* () { - const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; - const first = yield* bootstrapCredentials.issueOneTimeToken(); - const second = yield* bootstrapCredentials.issueOneTimeToken({ - scopes: ["orchestration:read", "access:write"], - }); - - const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); - expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); - expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); - for (const entry of activeBeforeRevoke) { - expect(entry).not.toHaveProperty("credential"); - } - - const revoked = yield* bootstrapCredentials.revoke(first.id); - const activeAfterRevoke = yield* bootstrapCredentials.listActive(); - const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); - - expect(revoked).toBe(true); - expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); - expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); - expect(revokedConsume.message).toContain("no longer available"); - expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); - }).pipe(Effect.provide(layerPairingGrantStore())), - ); - - it.effect("identifies consume-available failures and preserves their cause", () => { - const repositoryFailure = new PersistenceSqlError({ - operation: "consume-pairing-link", - detail: "Database unavailable", - cause: new Error("database unavailable"), - }); - - return Effect.gen(function* () { - const pairingGrants = yield* PairingGrantStore.PairingGrantStore; - const error = yield* Effect.flip(pairingGrants.consume("credential")); - - if (error._tag !== "BootstrapCredentialConsumeAvailableError") { - return yield* Effect.die(error); - } - expect(error.cause).toBe(repositoryFailure); - }).pipe( - Effect.provide( - layerPairingGrantStoreTest({ - consumeAvailable: () => Effect.fail(repositoryFailure), - }), - ), - ); - }); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 36a2bc34b569..2acdfb273ee1 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -254,7 +254,7 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 5dbcf5409807..857c1fbb659c 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,12 +8,8 @@ import { ProviderInstanceId, ThreadId, AuthOrchestrationOperateScope, - ORCHESTRATION_V2_WS_METHODS, AuthOrchestrationReadScope, AuthSourceControlWriteScope, - AuthPreviewOperateScope, - AuthRelayReadScope, - AuthRelayWriteScope, AuthTerminalReadScope, AuthTerminalOperateScope, WS_METHODS, @@ -37,27 +33,6 @@ describe("RPC authorization scopes", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); - it("requires operate access to stop a workflow", () => { - expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe( - AuthOrchestrationOperateScope, - ); - }); - - it("authorizes background policy reporting and observation deliberately", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( - AuthEnvironmentMaintainScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.subscribeBackgroundPolicy)).toBe( - AuthOrchestrationReadScope, - ); - }); - it("keeps webhook delivery logs, which hold request bodies, behind operate scope", () => { for (const method of [ WS_METHODS.scheduledTasksListWebhookDeliveries, @@ -68,19 +43,6 @@ describe("RPC authorization scopes", () => { } }); - it("allows relay status reads without granting relay installation access", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( - AuthRelayReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); - }); - - it("requires permission to operate on a thread before uploading feedback", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( - AuthOrchestrationOperateScope, - ); - }); - it("requires write access to import agent session history", () => { expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( AuthOrchestrationReadScope, @@ -93,55 +55,6 @@ describe("RPC authorization scopes", () => { ); }); - it("separates ACP Registry discovery from provisioning", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.serverSearchAcpRegistry)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe( - AuthProvidersManageScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe( - AuthProvidersManageScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe( - AuthProvidersManageScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverImportAcpRegistrySession)).toBe( - AuthOrchestrationOperateScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe( - AuthProvidersManageScope, - ); - }); - - it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsChecks)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsReviewerCandidates)).toBe( - requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail), - ); - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRequestReviewers)).toBe( - requiredScopeForRpcMethod(WS_METHODS.pullRequestsComment), - ); - }); - - it("requires source control writes to start, retry, or cancel project clones", () => { - for (const method of [ - WS_METHODS.projectCloneStart, - WS_METHODS.projectCloneRetry, - WS_METHODS.projectCloneCancel, - ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); - } - expect(requiredScopeForRpcMethod(WS_METHODS.subscribeProjectClones)).toBe( - AuthOrchestrationReadScope, - ); - }); - it("lists context repository clones as a read and removes them as a source control write", () => { expect(requiredScopeForRpcMethod(WS_METHODS.contextRepositoriesInspect)).toBe( AuthOrchestrationReadScope, @@ -151,38 +64,6 @@ describe("RPC authorization scopes", () => { ); }); - it("separates viewing pull request file progress from writing it", () => { - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsFilesViewed)).toBe( - AuthOrchestrationReadScope, - ); - expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsSetFilesViewed)).toBe( - AuthSourceControlWriteScope, - ); - }); - - it("separates preview control from observation", () => { - for (const method of [ - WS_METHODS.previewOpen, - WS_METHODS.previewNavigate, - WS_METHODS.previewResize, - WS_METHODS.previewRefresh, - WS_METHODS.previewClose, - WS_METHODS.previewReportStatus, - WS_METHODS.previewAdjust, - WS_METHODS.previewClearProfile, - WS_METHODS.previewReportProfiles, - ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthPreviewOperateScope); - } - for (const method of [ - WS_METHODS.previewList, - WS_METHODS.subscribePreviewEvents, - WS_METHODS.subscribeDiscoveredLocalServers, - ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); - } - }); - it("separates passive terminal observation from operations that can change a shell", () => { for (const method of [ WS_METHODS.terminalObserve, @@ -242,10 +123,6 @@ describe("RPC scope middleware", () => { scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope], missing: AuthDiagnosticsReadScope, }, - { - scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope], - missing: AuthEnvironmentMaintainScope, - }, ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) => Effect.gen(function* () { const handled: Array = []; @@ -271,27 +148,6 @@ describe("RPC scope middleware", () => { expect(handled).toEqual([]); }).pipe(Effect.scoped), ); - - it.effect("reports only authorized requests that succeed", () => - Effect.gen(function* () { - const observed: Array = []; - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.serverProbe, () => Effect.succeed({})), - group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => Effect.never), - RpcAuthorization.layer([AuthOrchestrationReadScope], (method) => - Effect.sync(() => void observed.push(method)), - ), - ), - ), - ); - - yield* client[WS_METHODS.serverProbe]({}); - yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip); - expect(observed).toEqual([WS_METHODS.serverProbe]); - }).pipe(Effect.scoped), - ); }); describe("settings mutation authorization", () => { @@ -435,7 +291,7 @@ describe("thread context authorization", () => { ), ); - it.effect.each(["commit", "push", "commit_push", "create_pr", "commit_push_pr"] as const)( + it.effect.each(["commit", "create_pr", "commit_push_pr"] as const)( "%s needs thread read permission only when generating a PR from a thread", (action) => Effect.gen(function* () { diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 539b35dc8fec..b3dd615c8d4d 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -71,33 +71,6 @@ const layerRenameFailureSecretStore = () => Layer.provideMerge(layerRenameFailureFileSystem), ); -const layerRemoveFailureFileSystem = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - - return { - ...fileSystem, - remove: (path, options) => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "remove", - pathOrDescriptor: String(path), - description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, - }), - ), - } satisfies FileSystem.FileSystem; - }), -).pipe(Layer.provide(NodeServices.layer)); - -const layerRemoveFailureSecretStore = () => - ServerSecretStore.layer.pipe( - Layer.provide(layerServerConfig()), - Layer.provideMerge(layerRemoveFailureFileSystem), - ); - const layerConcurrentReadMissFileSystem = Layer.effect( FileSystem.FileSystem, Effect.gen(function* () { @@ -145,16 +118,6 @@ const layerConcurrentCreateSecretStore = () => ); it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { - it.effect("returns Option.none when a secret file does not exist", () => - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore.ServerSecretStore; - - const secret = yield* secretStore.get("missing-secret"); - - assert.isTrue(Option.isNone(secret)); - }).pipe(Effect.provide(layerServerSecretStore())), - ); - it.effect("reuses an existing secret instead of regenerating it", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -185,45 +148,6 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { }).pipe(Effect.provide(layerConcurrentCreateSecretStore())), ); - it.effect("uses restrictive permissions for the secret directory and files", () => - Effect.gen(function* () { - const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; - const layerRecordingFileSystem = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - - return { - ...fileSystem, - makeDirectory: () => Effect.void, - writeFile: () => Effect.void, - rename: () => Effect.void, - chmod: (path, mode) => - Effect.sync(() => { - chmodCalls.push({ path: String(path), mode }); - }), - } satisfies FileSystem.FileSystem; - }), - ).pipe(Layer.provide(NodeServices.layer)); - - const secretStore = yield* Effect.service(ServerSecretStore.ServerSecretStore).pipe( - Effect.provide( - ServerSecretStore.layer.pipe( - Layer.provide(layerServerConfig()), - Layer.provideMerge(layerRecordingFileSystem), - ), - ), - ); - - yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); - - assert.isTrue( - chmodCalls.some((call) => call.mode === 0o700 && /[\\/]secrets$/.test(call.path)), - ); - assert.isAtLeast(chmodCalls.filter((call) => call.mode === 0o600).length, 2); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("propagates read failures other than missing-file errors", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -251,17 +175,4 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(layerRenameFailureSecretStore())), ); - - it.effect("propagates remove failures other than missing-file errors", () => - Effect.gen(function* () { - const secretStore = yield* ServerSecretStore.ServerSecretStore; - - const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - - assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); - assert.include(error.message, "Failed to remove secret session-signing-key."); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); - }).pipe(Effect.provide(layerRemoveFailureSecretStore())), - ); }); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index a3bab12d8b5b..020413ee8cd0 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -223,26 +223,41 @@ export const make = Effect.gen(function* () { ); }; + // `link` publishes the fully written temp file under the final name, or fails with + // AlreadyExists, so a concurrent reader never observes a partially written secret. const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); - return Effect.scoped( - Effect.gen(function* () { - const file = yield* fileSystem.open(secretPath, { - flag: "wx", - mode: 0o600, - }); - yield* file.writeAll(value); - yield* file.sync; - yield* fileSystem.chmod(secretPath, 0o600); - }), - ).pipe( + return crypto.randomUUIDv4().pipe( Effect.mapError( (cause) => - new SecretStorePersistError({ + new SecretStoreTemporaryPathError({ resource: `secret ${name}`, cause, }), ), + Effect.flatMap((uuid) => { + const tempPath = `${secretPath}.${uuid}.tmp`; + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(tempPath, { flag: "wx", mode: 0o600 }); + yield* file.writeAll(value); + yield* file.sync; + }), + ); + yield* fileSystem.chmod(tempPath, 0o600); + yield* fileSystem.link(tempPath, secretPath); + }).pipe( + Effect.ensuring(fileSystem.remove(tempPath).pipe(Effect.ignore)), + Effect.mapError( + (cause) => + new SecretStorePersistError({ + resource: `secret ${name}`, + cause, + }), + ), + ); + }), ); }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 4804250c8e95..41c4d6b74377 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -8,18 +8,14 @@ import * as Deferred from "effect/Deferred"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; import * as Redacted from "effect/Redacted"; -import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import { PersistenceSqlError } from "../persistence/Errors.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts"; @@ -86,34 +82,6 @@ const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(functio ); }); -const repositoryFailure = new PersistenceSqlError({ - operation: "AuthSessionRepository.getById:query", - detail: "sqlite is unavailable", -}); - -const layerFailingSessionLookupRepository = Layer.succeed(AuthSessions.AuthSessionRepository, { - create: () => Effect.void, - createReplacingActive: () => Effect.succeed([]), - createIfAbsent: () => Effect.void, - getById: () => Effect.fail(repositoryFailure), - listActive: () => Effect.succeed([]), - revoke: () => Effect.fail(repositoryFailure), - revokeAllExcept: () => Effect.fail(repositoryFailure), - setLastConnectedAt: () => Effect.void, - setClientConnection: () => Effect.void, -}); - -const layerFailingSessionLookupCredential = Layer.effect( - SessionStore.SessionStore, - SessionStore.make, -).pipe( - Layer.provide(layerFailingSessionLookupRepository), - Layer.provide(ServerSecretStore.layer), - Layer.provide(SqlitePersistence.layerMemory), - Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))), - Layer.provide(layerServerConfig()), -); - it.layer(NodeServices.layer)("SessionStore.layer", (it) => { it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)( "invalidates every connection for a session on %s and leaves other sessions active", @@ -279,56 +247,6 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }), ); - it.effect("keeps the reusable token active after normal sessions expire", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const token = "reusable-dev-auth-token-that-is-long-enough"; - const normal = yield* sessions.issue({ subject: "normal-session" }); - - yield* TestClock.adjust(Duration.days(31)); - - expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); - expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe( - "SessionTokenExpiredError", - ); - }).pipe( - Effect.provide( - Layer.merge( - layerSessionStore({ - mode: "web", - devUrl: new URL("http://127.0.0.1:5173"), - devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), - }), - TestClock.layer(), - ), - ), - ), - ); - - it.effect("issues and verifies signed browser session tokens", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue({ - subject: "desktop-bootstrap", - scopes: ["orchestration:read", "access:write"], - client: { - label: "Desktop app", - deviceType: "desktop", - os: "macOS", - browser: "Electron", - ipAddress: "127.0.0.1", - }, - }); - const verified = yield* sessions.verify(issued.token); - - expect(verified.method).toBe("browser-session-cookie"); - expect(verified.subject).toBe("desktop-bootstrap"); - expect(verified.scopes).toEqual(["orchestration:read", "access:write"]); - expect(verified.client.label).toBe("Desktop app"); - expect(verified.client.browser).toBe("Electron"); - expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); - }).pipe(Effect.provide(layerSessionStore())), - ); it.effect("carries a runtime-mode ceiling only on sessions issued with one", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; @@ -370,66 +288,6 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { ), ); - it.effect("rejects malformed session tokens", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const error = yield* Effect.flip(sessions.verify("not-a-session-token")); - - expect(error._tag).toBe("MalformedSessionTokenError"); - expect(error.message).toContain("Malformed session token"); - }).pipe(Effect.provide(layerSessionStore())), - ); - it.effect("preserves repository failures while verifying session and websocket credentials", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue({ - method: "bearer-access-token", - subject: "repository-failure", - }); - const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); - - const sessionError = yield* Effect.flip(sessions.verify(issued.token)); - const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId)); - const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId)); - - expect(sessionError._tag).toBe("SessionCredentialVerificationError"); - expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); - expect(sessionError.cause).toBe(repositoryFailure); - expect(websocketError.cause).toBe(repositoryFailure); - if (sessionError._tag === "SessionCredentialVerificationError") { - expect(sessionError.sessionId).toBe(issued.sessionId); - } - if (websocketError._tag === "WebSocketTokenVerificationError") { - expect(websocketError.sessionId).toBe(issued.sessionId); - } - expect(revokeError).toMatchObject({ - _tag: "SessionRevocationError", - sessionId: issued.sessionId, - cause: repositoryFailure, - }); - expect(revokeOthersError).toMatchObject({ - _tag: "OtherSessionsRevocationError", - currentSessionId: issued.sessionId, - cause: repositoryFailure, - }); - }).pipe(Effect.provide(layerFailingSessionLookupCredential)), - ); - it.effect("verifies session tokens against the Effect clock", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue({ - method: "bearer-access-token", - subject: "test-clock", - }); - const verified = yield* sessions.verify(issued.token); - - expect(verified.method).toBe("bearer-access-token"); - expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual(AuthStandardClientScopes); - }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - it.effect("atomically replaces active sessions with the same subject and method", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; @@ -567,44 +425,6 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), ); - it.effect("includes expiry context when session and websocket tokens expire", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue({ - method: "bearer-access-token", - subject: "short-lived-token", - ttl: Duration.seconds(1), - }); - const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, { - ttl: Duration.seconds(1), - }); - - yield* TestClock.adjust(Duration.seconds(2)); - - const sessionError = yield* Effect.flip(sessions.verify(issued.token)); - const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - - expect(sessionError._tag).toBe("SessionTokenExpiredError"); - if (sessionError._tag === "SessionTokenExpiredError") { - expect(sessionError.sessionId).toBe(issued.sessionId); - expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); - expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan( - sessionError.expiresAt.epochMilliseconds, - ); - } - expect(websocketError._tag).toBe("WebSocketTokenExpiredError"); - if (websocketError._tag === "WebSocketTokenExpiredError") { - expect(websocketError.sessionId).toBe(issued.sessionId); - expect(websocketError.expiresAt.epochMilliseconds).toBe( - websocket.expiresAt.epochMilliseconds, - ); - expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan( - websocketError.expiresAt.epochMilliseconds, - ); - } - }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; @@ -667,159 +487,6 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }).pipe(Effect.provide(layerSessionStore())), ); - it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue({ - subject: "reconnect-test", - method: "bearer-access-token", - }); - - const beforeConnect = yield* sessions.listActive(); - expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* sessions.markConnected(issued.sessionId); - const firstConnect = yield* sessions.listActive(); - const firstConnectedAt = firstConnect[0]?.lastConnectedAt; - - expect(firstConnect[0]?.connected).toBe(true); - expect(firstConnectedAt).not.toBeNull(); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* sessions.markConnected(issued.sessionId); - const stillConnected = yield* sessions.listActive(); - - expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); - - yield* sessions.markDisconnected(issued.sessionId); - yield* sessions.markDisconnected(issued.sessionId); - const afterDisconnect = yield* sessions.listActive(); - - expect(afterDisconnect[0]?.connected).toBe(false); - expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* sessions.markConnected(issued.sessionId); - const afterReconnect = yield* sessions.listActive(); - - expect(afterReconnect[0]?.connected).toBe(true); - expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); - expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); - }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - - it.effect("keeps connected relay sessions visible through expiry and HTTP renewal", () => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const original = yield* sessions.issue(relaySessionInput); - const websocket = yield* sessions.issueWebSocketToken(original.sessionId, { - ttl: Duration.hours(2), - }); - yield* sessions.verifyWebSocketToken(websocket.token); - yield* sessions.markConnected(original.sessionId); - const beforeExpiry = yield* sessions.listActive(); - expect(beforeExpiry).toHaveLength(1); - expect(beforeExpiry[0]?.connected).toBe(true); - - yield* TestClock.adjust(Duration.minutes(61)); - - expect(yield* sessions.listActive()).toEqual(beforeExpiry); - expect(yield* Effect.flip(sessions.verify(original.token))).toMatchObject({ - _tag: "SessionTokenExpiredError", - }); - expect(yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token))).toMatchObject({ - _tag: "WebSocketSessionExpiredError", - }); - - const renewed = yield* sessions.issue(relaySessionInput); - const afterRenewal = yield* sessions.listActive(); - expect(renewed.sessionId).not.toBe(original.sessionId); - expect(afterRenewal).toHaveLength(2); - expect(afterRenewal).toEqual( - expect.arrayContaining([ - beforeExpiry[0], - expect.objectContaining({ - sessionId: renewed.sessionId, - connected: false, - lastConnectedAt: null, - }), - ]), - ); - }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - - it.effect.each([1, 2])( - "removes an expired session from listings and updates after its last of %s sockets closes", - (socketCount) => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const issued = yield* sessions.issue(relaySessionInput); - const changes = yield* Queue.unbounded(); - yield* sessions.streamChanges.pipe( - Stream.runForEach((change) => Queue.offer(changes, change)), - Effect.forkScoped({ startImmediately: true }), - ); - for (let index = 0; index < socketCount; index += 1) { - yield* sessions.markConnected(issued.sessionId); - expect(yield* Queue.take(changes)).toMatchObject({ - type: "clientUpserted", - clientSession: { sessionId: issued.sessionId, connected: true }, - }); - } - - yield* TestClock.adjust(Duration.minutes(61)); - - for (let remaining = socketCount - 1; remaining >= 0; remaining -= 1) { - yield* sessions.markDisconnected(issued.sessionId); - const change = yield* Queue.take(changes); - const listed = yield* sessions.listActive(); - if (remaining > 0) { - expect(change).toMatchObject({ - type: "clientUpserted", - clientSession: { sessionId: issued.sessionId, connected: true }, - }); - expect(listed).toHaveLength(1); - expect(listed[0]?.connected).toBe(true); - } else { - expect(change).toEqual({ type: "clientRemoved", sessionId: issued.sessionId }); - expect(listed).toEqual([]); - } - } - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - - it.effect.each(["revoke", "revokeAllExcept"] as const)( - "removes expired connected sessions with %s", - (operation) => - Effect.gen(function* () { - const sessions = yield* SessionStore.SessionStore; - const administrative = yield* sessions.issue({ subject: "desktop-bootstrap" }); - const client = yield* sessions.issue(relaySessionInput); - yield* sessions.markConnected(client.sessionId); - yield* TestClock.adjust(Duration.minutes(61)); - const changes = yield* Queue.unbounded(); - yield* sessions.streamChanges.pipe( - Stream.runForEach((change) => Queue.offer(changes, change)), - Effect.forkScoped({ startImmediately: true }), - ); - - if (operation === "revoke") { - expect(yield* sessions.revoke(client.sessionId)).toBe(true); - } else { - expect(yield* sessions.revokeAllExcept(administrative.sessionId)).toBe(1); - } - - expect(yield* Queue.take(changes)).toEqual({ - type: "clientRemoved", - sessionId: client.sessionId, - }); - const listed = yield* sessions.listActive(); - expect(listed).toHaveLength(1); - expect(listed[0]?.sessionId).toBe(administrative.sessionId); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))), - ); - it.effect("records client connection metadata without clearing prior values", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 5c08e978f912..1f4ad752d2bd 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -482,7 +482,7 @@ function toAuthClientSession(input: Omit): AuthCli }; } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index ea8d1cd99db7..2b70975c88cf 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -26,33 +26,4 @@ describe("mapDpopReplayStoreError", () => { expect(error.dpopFailureReason).toBe("replay"); } }); - - it("reports replay-store availability failures as internal errors", () => { - const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - - expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); - if (error._tag === "ServerAuthDpopReplayStateRecordError") { - expect(error.message).toBe("Failed to record DPoP proof replay state."); - } - }); -}); - -describe("mapDpopFailureReason", () => { - it("maps verifier failures to safe client-facing categories", () => { - const mappings = [ - ["time_window", "time_window"], - ["key_mismatch", "key_mismatch"], - ["method_mismatch", "request_mismatch"], - ["url_mismatch", "request_mismatch"], - ["access_token_hash_mismatch", "token_mismatch"], - ["missing_proof", "invalid_proof"], - ["malformed_proof", "invalid_proof"], - ["invalid_signature", "invalid_proof"], - ["invalid_proof", "invalid_proof"], - ] as const; - - for (const [code, expected] of mappings) { - expect(mapDpopFailureReason(code)).toBe(expected); - } - }); }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 9d2262e6fc57..3e23879f1e06 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -20,7 +20,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-"; -export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { +const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { switch (code) { case "time_window": return "time_window"; diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 4060f0da3e30..39568bc0b1f1 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -1,23 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { - AuthSessionId, AuthTokenExchangeGrantType, AuthEnvironmentBootstrapTokenType, AuthAccessTokenType, - EnvironmentAuthenticatedAuth, EnvironmentHttpApi, } from "@t3tools/contracts"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import * as Tracer from "effect/Tracer"; -import { HttpServerRequest } from "effect/http"; import * as Etag from "effect/http/Etag"; import * as HttpPlatform from "effect/http/HttpPlatform"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -249,78 +243,3 @@ it.effect("issues read and operate agent access tokens only to access managers", ); }).pipe(Effect.provide(NodeServices.layer)), ); - -it.effect("exports only verified T3 Connect requests", () => - Effect.gen(function* () { - const productSpans: Array = []; - const localSpans: Array = []; - const collect = (into: Array) => - Tracer.make({ - span: (options) => { - into.push(options.name); - return new Tracer.NativeSpan(options); - }, - }); - const environmentAuth = { - authenticateHttpRequest: (request: HttpServerRequest.HttpServerRequest) => - (request.headers.authorization === "DPoP forged" - ? Effect.fail( - new EnvironmentAuth.ServerAuthInvalidCredentialError({ diagnostic: "forged" }), - ) - : Effect.succeed({ - sessionId: AuthSessionId.make("session-1"), - subject: - request.headers.authorization === "DPoP connect" - ? "cloud-connect" - : "cli-issued-session", - method: "bearer-access-token" as const, - scopes: ["orchestration:read" as const], - }) - ).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")), - } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]; - const middleware = yield* Layer.build(AuthHttp.layerAuthenticatedAuth).pipe( - Effect.provideService(EnvironmentAuth.EnvironmentAuth, environmentAuth), - Effect.map(Context.get(EnvironmentAuthenticatedAuth)), - ); - const handle = (authorization: string) => - ( - middleware as unknown as ( - effect: Effect.Effect, - ) => Effect.Effect - )(Effect.void.pipe(Effect.withSpan("environment.handler"))).pipe( - Effect.ignore, - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb( - new Request("https://environment.example.test/api/orchestration/shell", { - headers: { - authorization, - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }, - }), - ), - ), - Effect.provideService(RelayClientTracer, Option.some(collect(productSpans))), - Effect.withTracer(collect(localSpans)), - ); - - yield* handle("DPoP connect"); - expect(productSpans).toEqual([ - "environment.relay.request", - "EnvironmentAuth.authenticateHttpRequest", - "environment.handler", - ]); - expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); - - productSpans.length = 0; - localSpans.length = 0; - yield* handle("DPoP forged"); - expect(productSpans).toEqual([]); - expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest"]); - - localSpans.length = 0; - yield* handle("Bearer access-token"); - expect(productSpans).toEqual([]); - expect(localSpans).toEqual(["EnvironmentAuth.authenticateHttpRequest", "environment.handler"]); - }).pipe(Effect.scoped), -); diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index aebc9df5f437..63336b29c900 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - deriveAuthClientMetadata, - isRemoteReachableHost, - resolveSessionCookieName, -} from "./utils.ts"; +import { deriveAuthClientMetadata, resolveSessionCookieName } from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { @@ -81,6 +77,7 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); + // regression: #8085 isolate remote web session cookies it("isolates remote web servers by server state", () => { const first = resolveSessionCookieName({ mode: "web", @@ -124,39 +121,4 @@ describe("session cookie isolation", () => { expect(first).toBe(second); }); - - it("retains desktop port scoping", () => { - expect( - resolveSessionCookieName({ - mode: "desktop", - port: 3773, - host: "127.0.0.1", - instanceKey: "/tmp/desktop", - environmentId: "environment-one", - development: true, - }), - ).toBe("t3_session_3773"); - }); - - it("isolates development servers even when they bind a wildcard host", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 5775, - host: "0.0.0.0", - instanceKey: "/tmp/t3-wildcard-dev", - environmentId: "environment-one", - development: true, - }), - ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); - }); - - it("classifies loopback aliases separately from remotely reachable hosts", () => { - expect(isRemoteReachableHost(undefined)).toBe(false); - expect(isRemoteReachableHost("localhost")).toBe(false); - expect(isRemoteReachableHost("127.12.0.1")).toBe(false); - expect(isRemoteReachableHost("[::1]")).toBe(false); - expect(isRemoteReachableHost("0.0.0.0")).toBe(true); - expect(isRemoteReachableHost("192.168.1.50")).toBe(true); - }); }); diff --git a/apps/server/src/background/BackgroundPolicy.test.ts b/apps/server/src/background/BackgroundPolicy.test.ts index 078e3441d8ab..87da22e6cd40 100644 --- a/apps/server/src/background/BackgroundPolicy.test.ts +++ b/apps/server/src/background/BackgroundPolicy.test.ts @@ -33,12 +33,6 @@ const nominalHostPower: HostPowerSnapshot = { updatedAt: TEST_NOW, }; -const constrainedHostPower: HostPowerSnapshot = { - ...nominalHostPower, - lowPowerMode: "true", - stale: false, -}; - function makeReport(overrides: Partial = {}): ClientActivityReportInput { return { clientId: "client-1", @@ -78,26 +72,6 @@ function layerFor( } describe("BackgroundPolicy", () => { - it.effect("records foreground scoped client demand", () => - Effect.gen(function* () { - const policy = yield* BackgroundPolicy.BackgroundPolicy; - yield* policy.reportClientActivity( - AuthSessionId.make("session-1"), - RpcClientId.make(1), - makeReport(), - ); - - const snapshot = yield* policy.snapshot; - assert.equal(snapshot.activeForegroundLeaseCount, 1); - assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); - assert.equal(snapshot.shouldRunOpportunisticWork, true); - assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); - assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/other" }), false); - assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); - assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/other" }), false); - }).pipe(Effect.provide(layerFor(nominalHostPower))), - ); - it.effect("removes all leases for a disconnected websocket connection", () => Effect.gen(function* () { const policy = yield* BackgroundPolicy.BackgroundPolicy; @@ -224,24 +198,6 @@ describe("BackgroundPolicy", () => { }).pipe(Effect.provide(layerFor(nominalHostPower))), ); - it.effect("host low power mode disables opportunistic work without dropping scoped demand", () => - Effect.gen(function* () { - const policy = yield* BackgroundPolicy.BackgroundPolicy; - yield* policy.reportClientActivity( - AuthSessionId.make("session-1"), - RpcClientId.make(1), - makeReport(), - ); - - const snapshot = yield* policy.snapshot; - assert.equal(snapshot.activeForegroundLeaseCount, 1); - assert.deepStrictEqual(snapshot.activeScopeKeys, ["vcs-status:/repo"]); - assert.equal(snapshot.shouldRunOpportunisticWork, false); - assert.equal(yield* policy.hasDemand({ type: "vcs-status", cwd: "/repo" }), true); - assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); - }).pipe(Effect.provide(layerFor(constrainedHostPower))), - ); - it.effect("host suspension disables scoped and opportunistic work", () => Effect.gen(function* () { const policy = yield* BackgroundPolicy.BackgroundPolicy; @@ -300,39 +256,6 @@ describe("BackgroundPolicy", () => { }).pipe(Effect.provide(layerFor(nominalHostPower))), ); - it.effect("pauses scoped work for a visible unfocused client without recent interaction", () => - Effect.gen(function* () { - const policy = yield* BackgroundPolicy.BackgroundPolicy; - yield* policy.reportClientActivity( - AuthSessionId.make("session-1"), - RpcClientId.make(1), - makeReport({ focused: false, recentlyInteracted: false }), - ); - - const snapshot = yield* policy.snapshot; - assert.equal(snapshot.activeForegroundLeaseCount, 0); - assert.equal(snapshot.shouldRunOpportunisticWork, false); - assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), false); - }).pipe(Effect.provide(layerFor(nominalHostPower))), - ); - - it.effect( - "performance profile allows background scoped work while a scoped lease is active", - () => - Effect.gen(function* () { - const policy = yield* BackgroundPolicy.BackgroundPolicy; - yield* policy.reportClientActivity( - AuthSessionId.make("session-1"), - RpcClientId.make(1), - makeReport({ focused: false, visible: false }), - ); - - assert.equal(yield* policy.shouldRunScopeWork({ type: "vcs-status", cwd: "/repo" }), true); - }).pipe( - Effect.provide(layerFor(nominalHostPower, { backgroundActivityProfile: "performance" })), - ), - ); - it.effect("battery saver profile pauses scoped work on battery", () => Effect.gen(function* () { const policy = yield* BackgroundPolicy.BackgroundPolicy; diff --git a/apps/server/src/background/HostPowerMonitor.test.ts b/apps/server/src/background/HostPowerMonitor.test.ts index 2ccef15fa4fe..8b6034f7e0c7 100644 --- a/apps/server/src/background/HostPowerMonitor.test.ts +++ b/apps/server/src/background/HostPowerMonitor.test.ts @@ -6,7 +6,6 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -79,49 +78,6 @@ describe("HostPowerMonitor", () => { }), ); - it.effect("consumes desktop power directly without retaining diagnostics telemetry", () => - Effect.gen(function* () { - const sampledAt = DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"); - const desktopChanges = yield* PubSub.sliding(1); - const diagnosticsDemandWrites = yield* Ref.make(0); - const layerReceiver = DesktopTelemetryReceiver.layerTest({ - changes: Stream.fromPubSub(desktopChanges), - setDiagnosticsDemand: () => Ref.update(diagnosticsDemandWrites, (count) => count + 1), - }); - const layer = HostPowerMonitor.layer.pipe(Layer.provide(layerReceiver)); - - yield* Effect.gen(function* () { - const monitor = yield* HostPowerMonitor.HostPowerMonitor; - const nextPower = yield* Stream.runHead(monitor.streamChanges).pipe(Effect.forkChild); - yield* Effect.yieldNow; - yield* PubSub.publish(desktopChanges, { - version: 1, - type: "desktopTelemetry", - sequence: 1, - sampledAtUnixMs: DateTime.toEpochMillis(sampledAt), - electronPid: 100, - power: { - source: "electron-main", - idle: "false", - idleSeconds: 0, - locked: "false", - suspended: false, - onBattery: "true", - lowPowerMode: "unknown", - thermalState: "nominal", - stale: false, - updatedAt: sampledAt, - }, - speedLimitPercent: Option.none(), - electronProcesses: [], - }); - - expect(Option.getOrThrow(yield* Fiber.join(nextPower)).onBattery).toBe("true"); - expect(yield* Ref.get(diagnosticsDemandWrites)).toBe(0); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect( "subscribes before reading the desktop snapshot so concurrent power updates survive", () => diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index b0a2ceffef8f..2f7c24536caa 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -18,6 +18,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { extendConnectionAttemptTimeout, keepOutboundFetchOnHttp1 } from "./outboundHttp.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; +import { threadCommand } from "./cli/thread.ts"; import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { updateCommand } from "./cli/update.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; @@ -76,6 +77,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, browserCommand, projectCommand, + threadCommand, serviceCommand, updateCommand, uninstallCommand, diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 0b14c7dd1e23..8994aef465c9 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -1,24 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - BootstrapEnvelopeDecodeError, - BootstrapFdStatError, - BootstrapInputStreamOpenError, - readBootstrapEnvelope, -} from "./bootstrap.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ @@ -126,39 +116,6 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); - it.effect("preserves fd path, platform, and cause when opening the input stream fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(filePath, "r")), - (fd) => Effect.sync(() => closeIfOpen(fd)), - ); - const fdPath = `/proc/self/fd/${fd}`; - - openSyncInterceptor.failPath = fdPath; - openSyncInterceptor.errorCode = "EIO"; - try { - const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.provideService(HostProcess.Platform, "linux"), Effect.flip); - - assert.instanceOf(error, BootstrapInputStreamOpenError); - assert.equal(error.fd, fd); - assert.equal(error.platform, "linux"); - assert.equal(error.fdPath, fdPath); - assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO"); - assert.equal( - error.message, - `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`, - ); - } finally { - openSyncInterceptor.failPath = null; - openSyncInterceptor.errorCode = "ENXIO"; - } - }), - ); - it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { const fd = NodeFS.openSync(nullDevice, "r"); @@ -168,88 +125,4 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { assertNone(payload); }), ); - - it.effect("preserves fd and cause when stat fails for a non-availability reason", () => - Effect.gen(function* () { - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(nullDevice, "r")), - (fd) => Effect.sync(() => closeIfOpen(fd)), - ); - - fstatSyncInterceptor.failFd = fd; - try { - const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.flip); - - assert.instanceOf(error, BootstrapFdStatError); - assert.equal(error.fd, fd); - assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES"); - assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`); - } finally { - fstatSyncInterceptor.failFd = null; - } - }), - ); - - it.effect("preserves fd and schema cause when decoding the envelope fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - yield* fs.writeFileString(filePath, '{"mode":42}\n'); - - const fd = yield* openBootstrapInputFd(filePath); - const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.flip); - - assert.instanceOf(error, BootstrapEnvelopeDecodeError); - assert.equal(error.fd, fd); - assert.isDefined(error.cause); - assert.equal( - error.message, - `Failed to decode bootstrap envelope from file descriptor ${fd}.`, - ); - }), - ); - - // Needs a FIFO, which mkfifo creates; Windows has neither. - it.effect.skipIf(windowsHost)( - "returns none when the bootstrap read times out before any value arrives", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); - const fifoPath = NodePath.join(tempDir, "bootstrap.pipe"); - - yield* Effect.sync(() => NodeChildProcess.execFileSync("mkfifo", [fifoPath])); - - const _writer = yield* Effect.acquireRelease( - Effect.sync(() => - NodeChildProcess.spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { - stdio: ["ignore", "ignore", "ignore"], - }), - ), - (writer) => - Effect.sync(() => { - writer.kill("SIGKILL"); - }), - ); - - const fd = yield* Effect.acquireRelease( - Effect.sync(() => NodeFS.openSync(fifoPath, "r")), - (fd) => Effect.sync(() => closeIfOpen(fd)), - ); - - const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { - timeoutMs: 100, - }).pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(100)); - - const payload = yield* Fiber.join(fiber); - assertNone(payload); - }).pipe(Effect.provide(TestClock.layer())), - ); }); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 43034e483521..aae4a4a38b66 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -15,11 +15,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as CheckpointDiffQuery from "./CheckpointDiffQuery.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; -import { - CheckpointRefUnavailableError, - CheckpointThreadNotFoundError, - CheckpointTurnRangeUnavailableError, -} from "./Errors.ts"; +import { CheckpointRefUnavailableError, CheckpointTurnRangeUnavailableError } from "./Errors.ts"; const threadId = ThreadId.make("thread:checkpoint-diff-v2"); const firstRunId = RunId.make("run:checkpoint-diff-v2:1"); @@ -72,102 +68,6 @@ function layerFor(input: { ); } -it.effect("computes V2 run diffs from projected checkpoint scopes", () => { - const diffCheckpoints = vi.fn((_input: CheckpointStore.DiffCheckpointsInput) => - Effect.succeed("diff --git a/file b/file"), - ); - const layer = layerFor({ projection: Effect.succeed(makeProjection()), diffCheckpoints }); - - return Effect.gen(function* () { - const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; - const result = yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); - - assert.deepEqual(result, { - threadId, - fromTurnCount: 0, - toTurnCount: 2, - diff: "diff --git a/file b/file", - }); - assert.containSubset(diffCheckpoints.mock.calls[0]?.[0], { - cwd: "/repo", - fromCheckpointRef: yield* checkpointRefForScopeOrdinal({ - scopeId: firstScopeId, - ordinalWithinScope: 0, - }), - toCheckpointRef: secondRef, - fallbackFromToHead: false, - ignoreWhitespace: true, - }); - }).pipe(Effect.provide(layer)); -}); - -it.effect("leaves Git imports out of large turn diffs", () => { - const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`); - const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) => - Effect.succeed( - input.format === "numstat" - ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("") - : (input.filePaths ?? ["all"]).join("\n"), - ), - ); - const layer = layerFor({ - projection: Effect.succeed(makeProjection()), - diffCheckpoints, - authoredPaths: new Set(ownPaths), - }); - - return Effect.gen(function* () { - const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; - yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 }); - - const patchPaths = diffCheckpoints.mock.calls - .map(([call]) => call) - .filter((call) => call.format !== "numstat") - .map((call) => call.filePaths ?? ["all"]); - assert.isAbove(patchPaths.length, 1); - assert.sameMembers(patchPaths.flat(), ownPaths); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves the typed missing-thread error contract", () => { - const layer = layerFor({ - projection: Effect.fail(new OrchestratorProjectionError({ threadId })), - }); - - return Effect.gen(function* () { - const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; - const error = yield* query - .getTurnDiff({ threadId, fromTurnCount: 0, toTurnCount: 1 }) - .pipe(Effect.flip); - - assert.instanceOf(error, CheckpointThreadNotFoundError); - assert.deepEqual( - { operation: error.operation, threadId: error.threadId }, - { operation: "CheckpointDiffQuery.getTurnDiff", threadId }, - ); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves the typed unavailable-range error contract", () => { - const layer = layerFor({ projection: Effect.succeed(makeProjection()) }); - - return Effect.gen(function* () { - const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; - const error = yield* query - .getTurnDiff({ threadId, fromTurnCount: 0, toTurnCount: 3 }) - .pipe(Effect.flip); - - assert.instanceOf(error, CheckpointTurnRangeUnavailableError); - assert.deepEqual( - { - requestedTurnCount: error.requestedTurnCount, - availableTurnCount: error.availableTurnCount, - }, - { requestedTurnCount: 3, availableTurnCount: 2 }, - ); - }).pipe(Effect.provide(layer)); -}); - it.effect("excludes ready checkpoints from rolled-back runs", () => { const projection = makeProjection(); const layer = layerFor({ @@ -290,32 +190,6 @@ it.effect.each([false, true])( }, ); -it.effect("preserves the typed missing-baseline-ref error contract", () => { - const projection = makeProjection(); - const layer = layerFor({ - projection: Effect.succeed({ - ...projection, - checkpointScopes: projection.checkpointScopes.map((scope) => ({ - ...scope, - kind: "tool" as const, - })), - }), - }); - - return Effect.gen(function* () { - const query = yield* CheckpointDiffQuery.CheckpointDiffQuery; - const error = yield* query - .getTurnDiff({ threadId, fromTurnCount: 0, toTurnCount: 2 }) - .pipe(Effect.flip); - - assert.instanceOf(error, CheckpointRefUnavailableError); - assert.deepEqual( - { checkpoint: error.checkpoint, turnCount: error.turnCount }, - { checkpoint: "from", turnCount: 0 }, - ); - }).pipe(Effect.provide(layer)); -}); - const layerVcsProcess = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const layerStore = CheckpointStore.layer.pipe( Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(layerVcsProcess))), diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 00b7437caba4..c253d6cbe988 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -102,27 +102,6 @@ function buildLargeText(lineCount = 5_000): string { } it.layer(layerTest)("CheckpointStore.layer", (it) => { - describe("isGitRepository", () => { - it.effect("returns false when no Git repository is detected", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const checkpointStore = yield* CheckpointStore.CheckpointStore; - - expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); - }), - ); - - it.effect("returns true when a Git repository is detected", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore.CheckpointStore; - - expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); - }), - ); - }); - it.effect("detects a nested workspace without its own .git entry", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -531,38 +510,5 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { ); }), ); - - it.effect("leaves out paths that only a rebase onto upstream brought in", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const checkpointStore = yield* CheckpointStore.CheckpointStore; - const threadId = ThreadId.make("checkpoint-authored-paths-rebase"); - const refs = { - cwd: tmp, - fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0), - toCheckpointRef: checkpointRefForThreadTurn(threadId, 1), - }; - const commitBeforeTurn = (file: string) => - Effect.gen(function* () { - yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" }); - }); - - yield* git(tmp, ["checkout", "-b", "upstream"]); - yield* commitBeforeTurn("upstream.txt"); - yield* git(tmp, ["checkout", "-"]); - yield* commitBeforeTurn("feature.txt"); - yield* checkpointStore.captureCheckpoint({ - cwd: tmp, - checkpointRef: refs.fromCheckpointRef, - }); - yield* git(tmp, ["rebase", "upstream"]); - yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef }); - - expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"])); - }), - ); }); }); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index 441643fbda63..88fc507382cf 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -3,10 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; describe("parseTurnDiffFilesFromNumstat", () => { - it("returns an empty list when no files changed", () => { - expect(parseTurnDiffFilesFromNumstat("")).toEqual([]); - }); - it("sorts files and preserves addition and deletion counts", () => { const numstat = ["0\t2\tsrc/b.ts", "2\t1\ta.txt", ""].join("\0"); expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index 1c4a8a6c99db..4a34d98a796b 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -30,17 +30,6 @@ describe("compileClaudeModelSelection", () => { }); }); - it("compiles fast mode only for models that expose it", () => { - expect( - compileClaudeModelSelection(selection("claude-opus-4-6", [{ id: "fastMode", value: true }])) - .settings, - ).toEqual({ fastMode: true }); - expect( - compileClaudeModelSelection(selection("claude-opus-4-6", [{ id: "fastMode", value: false }])) - .settings, - ).toEqual({ fastMode: false }); - }); - it("uses the model default SDK effort alongside prompt-injected effort", () => { expect( compileClaudeModelSelection( @@ -59,19 +48,4 @@ describe("compileClaudeModelSelection", () => { compileClaudeModelSelection(selection("claude-pool-model-not-in-catalog", [])).effort, ).toBeUndefined(); }); - - it("omits unsupported effort for models missing from the local catalog", () => { - expect( - compileClaudeModelSelection( - selection("claude-pool-model-not-in-catalog", [{ id: "effort", value: "brutal" }]), - ).effort, - ).toBeUndefined(); - }); - - it("compiles the thinking toggle for models that expose it", () => { - expect( - compileClaudeModelSelection(selection("claude-haiku-4-5", [{ id: "thinking", value: false }])) - .settings, - ).toEqual({ alwaysThinkingEnabled: false }); - }); }); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index d8c1ab8e04ee..b4184663625e 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -166,17 +166,6 @@ describe("t3 server command safety", () => { ), ); - it.effect("shows help without creating state", () => - withTempDirectory("t3-cli-help-", (root) => - Effect.gen(function* () { - const baseDir = NodePath.join(root, "home"); - const help = yield* runCli(["help"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); - expect(help).toMatchObject({ _tag: "ShowHelp", commandPath: ["t3"], errors: [] }); - expect(yield* pathExists(baseDir)).toBe(false); - }), - ), - ); - it.effect("refuses manual startup over a live server before creating directories", () => withTempDirectory("t3-cli-running-", (root) => Effect.gen(function* () { @@ -226,61 +215,6 @@ describe("t3 server command safety", () => { }); describe("t3 app", () => { - it.effect("rejects SSH before it tries to reach a desktop app", () => - withTempDirectory("t3-app-ssh-test-", (root) => - Effect.gen(function* () { - const baseDir = NodePath.join(root, "missing-t3-home"); - const error = yield* runCli(["app", "--base-dir", baseDir], { - SSH_CONNECTION: "client server", - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "DesktopAppSshUnsupportedError", - message: - "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.", - }); - expect(yield* pathExists(baseDir)).toBe(false); - }), - ), - ); - - it.effect("rejects unsupported platforms without creating state", () => - withTempDirectory("t3-app-platform-test-", (root) => - Effect.gen(function* () { - const baseDir = NodePath.join(root, "missing-t3-home"); - const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( - Effect.provideService(HostProcess.Platform, "freebsd"), - Effect.flip, - ); - - expect(error).toMatchObject({ - _tag: "DesktopAppPlatformUnsupportedError", - platform: "freebsd", - message: "`t3 app` is not supported on freebsd.", - }); - expect(yield* pathExists(baseDir)).toBe(false); - }), - ), - ); - - it.effect("does not create state when only a server or no desktop app is running", () => - withTempDirectory("t3-app-missing-test-", (root) => - Effect.gen(function* () { - const baseDir = NodePath.join(root, "missing-t3-home"); - const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "DesktopAppUnreachableError", - candidateAddresses: [expect.any(String)], - workspaceRoot: yield* HostProcess.WorkingDirectory, - message: expect.stringContaining("Could not reach the T3 Code desktop app."), - cause: { code: "ENOENT" }, - }); - expect(yield* pathExists(baseDir)).toBe(false); - }), - ), - ); - it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () => withTempDirectory("t3-app-command-test-", (root) => Effect.gen(function* () { diff --git a/apps/server/src/cli/browser.test.ts b/apps/server/src/cli/browser.test.ts deleted file mode 100644 index 0646ccfc773c..000000000000 --- a/apps/server/src/cli/browser.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestConsole from "effect/testing/TestConsole"; -import { Command } from "effect/cli"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; - -import { browserCommand } from "./browser.ts"; - -const BROWSER = "/t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell"; -const FONT = "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"; - -/** - * Records every command. `fc-match` is missing until fonts are installed, unless - * the host starts with a font; the browser fails to load libnss3 when asked to. - */ -const fakeSpawner = (host: { readonly font?: string; readonly missingLibrary?: boolean }) => { - const commands: Array> = []; - let font = host.font; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("Unexpected pipeline"); - commands.push([command.command, ...command.args]); - if (command.command === "apt-get" && command.args.includes("fonts-liberation")) font = FONT; - if (command.command === "fc-match" && font === undefined) { - return yield* PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }); - } - const output = - command.command === "fc-match" - ? font! - : command.command === BROWSER && host.missingLibrary - ? "error while loading shared libraries: libnss3.so: cannot open shared object file" - : command.command === "ldd" - ? "\tlibnss3.so => not found\n" - : ""; - const bytes = Stream.make(new TextEncoder().encode(output)); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: bytes, - stderr: Stream.empty, - all: bytes, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - return { spawner, commands }; -}; - -/** A Linux host with apt and T3's browser installed under `/t3`. */ -const runSetup = (host: { - readonly root: boolean; - readonly font?: string; - readonly missingLibrary?: boolean; -}) => { - const { spawner, commands } = fakeSpawner(host); - const fs = FileSystem.makeNoop({ - exists: (path) => Effect.succeed(path === "/usr/bin/apt-get" || path === BROWSER), - readDirectory: (path) => - Effect.succeed(path.endsWith("chrome-headless-shell") ? ["linux64"] : ["154"]), - }); - return Effect.gen(function* () { - yield* Command.runWith(browserCommand, { version: "0.0.0" })(["setup", "--base-dir", "/t3"]); - return { commands, output: (yield* TestConsole.logLines).join("\n") }; - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.UserId, host.root ? 0 : 1000), - // Root through sudo from a regular user, so setup reports ready instead of the root warning. - Effect.provideService(HostProcess.Environment, host.root ? { SUDO_USER: "alice" } : {}), - Effect.provide(Layer.mergeAll(NodeServices.layer, TestConsole.layer)), - ); -}; - -const aptGet = (commands: ReadonlyArray>) => - commands.filter(([name]) => name === "apt-get"); - -it.effect("setup in a root shell warns that the server cannot run as root", () => - Effect.gen(function* () { - // Every file read fails, so setup finds nothing to install and changes nothing. - yield* Command.runWith(browserCommand, { version: "0.0.0" })([ - "setup", - "--base-dir", - "/nonexistent", - ]).pipe( - Effect.provide(FileSystem.layerNoop({})), - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - fakeSpawner({ font: FONT }).spawner, - ), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.UserId, 0), - // A root user who follows the old advice runs setup through sudo. - Effect.provideService(HostProcess.Environment, { SUDO_USER: "root" }), - ); - const output = (yield* TestConsole.logLines).join("\n"); - expect(output).toContain("Chrome will not start its sandbox as root"); - expect(output).not.toContain("ready"); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, TestConsole.layer))), -); - -it.effect("setup without root does not report a host without fonts as ready", () => - Effect.gen(function* () { - const { commands, output } = yield* runSetup({ root: false }); - expect(output).toContain( - "T3's browser needs fontconfig and system fonts; setup installs them.", - ); - expect(output).toContain("This needs root. Run:"); - expect(output).not.toContain("ready"); - expect(aptGet(commands)).toEqual([]); - }), -); - -it.effect("setup as root installs missing libraries and fonts after one package refresh", () => - Effect.gen(function* () { - const { commands, output } = yield* runSetup({ root: true, missingLibrary: true }); - const apt = aptGet(commands); - expect(apt.filter(([, action]) => action === "update")).toHaveLength(1); - expect(apt.find((args) => args.includes("libnss3"))).toBeDefined(); - expect(apt).toContainEqual([ - "apt-get", - "install", - "-y", - "--no-install-recommends", - "fontconfig", - "fonts-liberation", - ]); - expect(output).toContain("Installed the browser's fonts."); - expect(output).toContain("This host is ready for T3's browser."); - }), -); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index b1258de886b8..7b7d6bf75226 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -29,21 +29,6 @@ import { resolveServerConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); -const minimalDesktopFlags = (baseDir: string) => ({ - mode: Option.some("desktop" as const), - port: Option.some(4888), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), -}); - const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -294,149 +279,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("falls back to effect/config values when flags are omitted", () => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-env-base"); - const derivedPaths = yield* deriveExplicitServerPaths( - baseDir, - new URL("http://127.0.0.1:5173"), - ); - const resolved = yield* resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: Option.none(), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_LOG_LEVEL: "Warn", - T3CODE_MODE: "desktop", - T3CODE_PORT: "4001", - T3CODE_HOST: "0.0.0.0", - T3CODE_HOME: baseDir, - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - T3CODE_DEV_ALLOWED_ORIGINS: - "https://host.example.ts.net, https://phone.example.ts.net ", - T3CODE_NO_BROWSER: "true", - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", - T3CODE_LOG_WS_EVENTS: "true", - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved).toEqual({ - logLevel: "Warn", - ...defaultObservabilityConfig, - mode: "desktop", - port: 4001, - cwd: process.cwd(), - baseDir, - ...derivedPaths, - host: "0.0.0.0", - staticDir: undefined, - devUrl: new URL("http://127.0.0.1:5173"), - devAllowedOrigins: ["https://host.example.ts.net", "https://phone.example.ts.net"], - noBrowser: true, - startupPresentation: "browser", - desktopBootstrapToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); - assert.equal(resolved.stateDir, join(baseDir, "userdata")); - }), - ); - - it.effect("uses CLI flags when provided", () => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-flags-base"); - const derivedPaths = yield* deriveExplicitServerPaths( - baseDir, - new URL("http://127.0.0.1:4173"), - ); - const resolved = yield* resolveServerConfig( - { - mode: Option.some("web"), - port: Option.some(8788), - host: Option.some("127.0.0.1"), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.some(new URL("http://127.0.0.1:4173")), - noBrowser: Option.some(true), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.some(true), - logWebSocketEvents: Option.some(true), - tailscaleServeEnabled: Option.some(true), - tailscaleServePort: Option.some(8443), - }, - Option.some("Debug"), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_LOG_LEVEL: "Warn", - T3CODE_MODE: "desktop", - T3CODE_PORT: "4001", - T3CODE_HOST: "0.0.0.0", - T3CODE_HOME: join(NodeOS.tmpdir(), "ignored-base"), - VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", - T3CODE_NO_BROWSER: "false", - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", - T3CODE_LOG_WS_EVENTS: "false", - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved).toEqual({ - logLevel: "Debug", - ...defaultObservabilityConfig, - mode: "web", - port: 8788, - cwd: process.cwd(), - baseDir, - ...derivedPaths, - host: "127.0.0.1", - staticDir: undefined, - devUrl: new URL("http://127.0.0.1:4173"), - noBrowser: true, - startupPresentation: "browser", - desktopBootstrapToken: undefined, - autoBootstrapProjectFromCwd: true, - logWebSocketEvents: true, - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - }); - assert.equal(resolved.dbPath, join(baseDir, "userdata", "statev2.sqlite")); - }), - ); - it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -509,170 +351,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => - Effect.gen(function* () { - const { join, resolve } = yield* Path.Path; - // The resolver absolutises the configured home, so the expectation must - // carry the host's drive on Windows. - const baseDir = resolve("/tmp/t3-bootstrap-home"); - const fd = yield* openBootstrapFd( - makeDesktopBootstrap({ - port: 4888, - host: "127.0.0.2", - t3Home: "/tmp/t3-bootstrap-home", - noBrowser: true, - desktopBootstrapToken: "desktop-token", - desktopTelemetryFd: 4, - desktopTelemetryControlFd: 5, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - }), - ); - const derivedPaths = yield* deriveServerPaths(baseDir, undefined); - - const resolved = yield* resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: Option.none(), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_BOOTSTRAP_FD: String(fd), - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved).toEqual({ - logLevel: "Info", - ...defaultObservabilityConfig, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - mode: "desktop", - port: 4888, - cwd: process.cwd(), - baseDir, - ...derivedPaths, - host: "127.0.0.2", - staticDir: resolved.staticDir, - devUrl: undefined, - noBrowser: true, - startupPresentation: "browser", - desktopBootstrapToken: "desktop-token", - desktopTelemetryFd: 4, - desktopTelemetryControlFd: 5, - resourceMonitorPath: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); - assert.equal(join(baseDir, "userdata"), resolved.stateDir); - assert.equal(resolved.desktopTelemetryFd, 4); - assert.equal(resolved.desktopTelemetryControlFd, 5); - }), - ); - - it.effect("carries the desktop's shell environment handoff only when the envelope sets it", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-shell-env-" }); - const resolveWith = Effect.fn(function* (overrides: Partial) { - const fd = yield* openBootstrapFd(makeDesktopBootstrap(overrides)); - return yield* resolveServerConfig( - { - ...minimalDesktopFlags(baseDir), - bootstrapFd: Option.some(fd), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), - NetService.layer, - ), - ), - ); - }); - - assert.equal((yield* resolveWith({})).shellEnvironmentPrepared, undefined); - assert.equal( - (yield* resolveWith({ shellEnvironmentPrepared: true })).shellEnvironmentPrepared, - true, - ); - }), - ); - - it.effect("creates derived runtime directories during config resolution", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" }); - const customCwd = path.join(baseDir, "nested", "project"); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("desktop"), - port: Option.some(4888), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.some(customCwd), - devUrl: Option.some(new URL("http://127.0.0.1:5173")), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), - NetService.layer, - ), - ), - ); - - for (const directory of [ - customCwd, - resolved.stateDir, - resolved.logsDir, - resolved.providerLogsDir, - resolved.terminalLogsDir, - resolved.attachmentsDir, - resolved.worktreesDir, - path.dirname(resolved.serverTracePath), - ]) { - expect(yield* fs.exists(directory)).toBe(true); - } - expect(resolved.cwd).toBe(path.resolve(customCwd)); - }), - ); - it.effect("applies flag then env precedence over bootstrap envelope values", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -751,77 +429,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("falls back to persisted observability settings when env vars are absent", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); - const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); - yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); - yield* fs.writeFileString( - derivedPaths.settingsPath, - `${JSON.stringify({ - observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - }, - })}\n`, - ); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("desktop"), - port: Option.some(4888), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); - expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); - expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs"); - expect(resolved).toEqual({ - logLevel: "Info", - ...defaultObservabilityConfig, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - mode: "desktop", - port: 4888, - cwd: process.cwd(), - baseDir, - ...derivedPaths, - host: "127.0.0.1", - staticDir: resolved.staticDir, - devUrl: undefined, - noBrowser: true, - startupPresentation: "browser", - desktopBootstrapToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); - }), - ); - it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -985,49 +592,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("decodes percent-encoded OTLP headers from env", () => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-headers-base"); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("web"), - port: Option.some(3773), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3", - }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpTracesExport.headers).toEqual({ - authorization: "Basic abc==", - "x-tenant": "t3", - }); - }), - ); - it.effect("keeps whitespace-separated pairs and literal equals signs in OTLP headers", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -1073,84 +637,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("gives every signal the protocol named without one", () => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-protocol-base"); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("web"), - port: Option.some(3773), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ env: { T3CODE_OTLP_PROTOCOL: "http/protobuf" } }), - ), - NetService.layer, - ), - ), - ); - - expect([ - resolved.otlpTracesExport.protocol, - resolved.otlpMetricsExport.protocol, - resolved.otlpLogsExport.protocol, - ]).toEqual(["http/protobuf", "http/protobuf", "http/protobuf"]); - }), - ); - - it.effect("reads the OTLP logs URL from env", () => - Effect.gen(function* () { - const { join } = yield* Path.Path; - const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-logs-url-base"); - - const resolved = yield* resolveServerConfig( - { - mode: Option.some("web"), - port: Option.some(3773), - host: Option.none(), - baseDir: Option.some(baseDir), - cwd: Option.none(), - devUrl: Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - Option.none(), - ).pipe( - Effect.provide( - Layer.mergeAll( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { T3CODE_OTLP_LOGS_URL: "http://collector.internal:4318/v1/logs" }, - }), - ), - NetService.layer, - ), - ), - ); - - expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); - }), - ); - const minimalWebFlags = (baseDir: string) => ({ mode: Option.some("web" as const), port: Option.some(3773), diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 72a17f56e93e..6693d214faba 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -1,29 +1,9 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as References from "effect/References"; -import * as Terminal from "effect/Terminal"; -import * as BootService from "../cloud/bootService.ts"; -import { - acquireRelayClientForLink, - headlessSessionConfig, - reportCloudDisconnectResults, - reportServiceOnboarding, -} from "./connect.ts"; -import { offerServiceDuringOnboarding, recoverServiceOnboardingOffer } from "./service.ts"; - -const readHeadlessSessionConfig = (env: Record) => - headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); +import { acquireRelayClientForLink } from "./connect.ts"; const managedExecutable = { status: "available", @@ -32,95 +12,6 @@ const managedExecutable = { version: RelayClient.CLOUDFLARED_VERSION, } as const; -it.effect("detects headless operation from individual SSH config values", () => - Effect.gen(function* () { - assert.isFalse(yield* readHeadlessSessionConfig({})); - assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" })); - assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" })); - assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" })); - }), -); - -it.effect("treats cancelling optional background setup as a successful skip", () => - Effect.gen(function* () { - const result = yield* recoverServiceOnboardingOffer(Effect.fail(new Terminal.QuitError({}))); - assert.equal(result, "skipped"); - }), -); - -it.effect("keeps a successful connection when a remote service update is pending", () => - Effect.gen(function* () { - const result = yield* recoverServiceOnboardingOffer( - Effect.fail(new BootService.BootServiceUpdatePendingError()), - ); - assert.equal(result, "skipped"); - }), -); - -it.effect("reports pending onboarding until the handed-off service is ready", () => - Effect.gen(function* () { - const input = yield* Queue.make(); - yield* Queue.offer(input, { - input: Option.some("\r"), - key: { name: "return", ctrl: false, meta: false, shift: false }, - }); - const terminal = Terminal.make({ - columns: Effect.succeed(80), - rows: Effect.succeed(24), - readInput: Effect.succeed(Queue.asDequeue(input)), - readLine: Effect.die("unexpected line input"), - display: () => Effect.void, - }); - const plan = { - program: ["/test/t3", "__service-launcher"], - baseDir: "/test", - unitPath: "/test/t3code.service", - logPath: "/test/boot-service.log", - handedOff: true, - }; - let current = false; - const service = BootService.BootService.of({ - status: Effect.sync(() => ({ - supported: true, - installed: true, - current, - unitPath: plan.unitPath, - logPath: plan.logPath, - })), - install: () => Effect.succeed(plan), - restart: Effect.die("unexpected restart"), - uninstall: Effect.die("unexpected uninstall"), - withStopped: () => Effect.die("unexpected maintenance"), - }); - const lines: string[] = []; - const onboard = recoverServiceOnboardingOffer(offerServiceDuringOnboarding).pipe( - Effect.flatMap(reportServiceOnboarding), - Effect.provideService(BootService.BootService, service), - Effect.provideService(Terminal.Terminal, terminal), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Console.Console, { - ...globalThis.console, - log: (...args) => { - lines.push(args.map(String).join(" ")); - }, - }), - ); - - yield* onboard; - const output = lines.join("\n"); - assert.notInclude(output, "Background service ready"); - assert.notInclude(output, "Start the server"); - assert.include(output, "Background service restarting"); - assert.include(output, plan.logPath); - - current = true; - lines.length = 0; - yield* onboard; - assert.include(lines.join("\n"), "Background service ready"); - assert.notInclude(lines.join("\n"), "restarting"); - }).pipe(Effect.provide(NodeServices.layer)), -); - it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { let installCalls = 0; @@ -149,179 +40,3 @@ it.effect("does not install the relay client when the user declines the managed assert.equal(installCalls, 0); }), ); - -it.effect("installs the relay client after the user accepts the managed download", () => - Effect.gen(function* () { - let installCalls = 0; - const progress: Array = []; - const result = yield* acquireRelayClientForLink( - { - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - installWithProgress: (report) => - report({ type: "progress", stage: "downloading" }).pipe( - Effect.andThen( - Effect.sync(() => { - installCalls += 1; - return managedExecutable; - }), - ), - ), - pruneManagedVersions: Effect.void, - }, - () => Effect.succeed(true), - (event) => - Effect.sync(() => { - if (event.type === "progress") { - progress.push(event.stage); - } - }), - ); - - assert.deepEqual(Option.getOrThrow(result), managedExecutable); - assert.equal(installCalls, 1); - assert.deepEqual(progress, ["downloading"]); - }), -); - -it.effect("reuses an available relay client executable without prompting", () => - Effect.gen(function* () { - let promptCalls = 0; - const result = yield* acquireRelayClientForLink( - { - resolve: Effect.succeed(managedExecutable), - install: Effect.die("unexpected install"), - installWithProgress: () => Effect.die("unexpected install"), - pruneManagedVersions: Effect.void, - }, - () => - Effect.sync(() => { - promptCalls += 1; - return false; - }), - () => Effect.void, - ); - - assert.deepEqual(Option.getOrThrow(result), managedExecutable); - assert.equal(promptCalls, 0); - }), -); - -it.effect("keeps disconnect causes in structured logs and out of console warnings", () => { - const warnings: ReadonlyArray[] = []; - const logs: Readonly>[] = []; - const testConsole = { - ...globalThis.console, - warn: (...args: ReadonlyArray) => { - warnings.push(args); - }, - } satisfies Console.Console; - const logger = Logger.make(({ fiber }) => { - logs.push(fiber.getRef(References.CurrentLogAnnotations)); - }); - const liveFailure = "live unlink private diagnostic"; - const relayFailure = "relay revoke private diagnostic"; - - return reportCloudDisconnectResults({ - clearAuthorization: true, - liveResult: { - status: "failed", - cause: Cause.fail(new Error(liveFailure)), - }, - relayResult: Exit.failCause(Cause.die(new Error(relayFailure))), - }).pipe( - Effect.provideService(Console.Console, testConsole), - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - Effect.tap(() => - Effect.sync(() => { - assert.lengthOf(warnings, 2); - const warningText = warnings.flat().map(String).join("\n"); - assert.include(warningText, "running server could not stop its tunnel"); - assert.include(warningText, "Could not revoke the relay-side environment record"); - assert.notInclude(warningText, liveFailure); - assert.notInclude(warningText, relayFailure); - assert.deepEqual( - logs.map(({ operation, clearAuthorization }) => ({ operation, clearAuthorization })), - [ - { operation: "live-server-unlink", clearAuthorization: true }, - { operation: "relay-environment-unlink", clearAuthorization: true }, - ], - ); - const loggedCauses = logs.map((log) => String(log.cause)).join("\n"); - assert.include(loggedCauses, liveFailure); - assert.include(loggedCauses, relayFailure); - }), - ), - ); -}); - -/** Captures console lines from a successful local disconnect report. */ -const captureDisconnectReport = (input: { - readonly clearAuthorization: boolean; - readonly liveResult: { - readonly status: "succeeded"; - }; - readonly relayResult: Exit.Exit< - { readonly status: "not-authenticated" } | { readonly status: "revoked" }, - unknown - >; -}) => { - const lines: Array<{ readonly stream: "log" | "warn"; readonly text: string }> = []; - const testConsole = { - ...globalThis.console, - log: (...args: ReadonlyArray) => { - lines.push({ stream: "log", text: args.map(String).join(" ") }); - }, - warn: (...args: ReadonlyArray) => { - lines.push({ stream: "warn", text: args.map(String).join(" ") }); - }, - } satisfies Console.Console; - - return reportCloudDisconnectResults(input).pipe( - Effect.provideService(Console.Console, testConsole), - Effect.provide(Logger.layer([Logger.make(() => undefined)], { mergeWithExisting: false })), - Effect.as(lines), - ); -}; - -it.effect("warns to deregister when unlink has no stored CLI authorization", () => - Effect.gen(function* () { - const lines = yield* captureDisconnectReport({ - clearAuthorization: false, - liveResult: { status: "succeeded" }, - relayResult: Exit.succeed({ status: "not-authenticated" }), - }); - - assert.deepEqual( - lines.map((line) => line.stream), - ["log", "warn"], - ); - assert.equal(lines[0]?.text, "T3 Connect is disabled locally."); - assert.include(lines[1]?.text, "The relay-side environment record was not revoked."); - assert.include(lines[1]?.text, "T3 Connect page of your T3 account"); - assert.include(lines[1]?.text, "`t3 connect login`"); - assert.notInclude(lines.map((line) => line.text).join("\n"), "Revoked the relay-side"); - }), -); - -it.effect("points a failed relay unlink at retry and account deregistration", () => - Effect.gen(function* () { - const lines = yield* captureDisconnectReport({ - clearAuthorization: false, - liveResult: { status: "succeeded" }, - relayResult: Exit.fail("relay unreachable"), - }); - const warning = lines.find((line) => line.stream === "warn")?.text ?? ""; - - assert.equal(lines[0]?.text, "T3 Connect is disabled locally."); - assert.include(warning, "Run `t3 connect unlink` again when the relay is reachable"); - assert.include(warning, "deregister it from the T3 Connect page of your T3 account"); - assert.notInclude(warning, "relay unreachable"); - }), -); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 94f338cef087..76644be57e14 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -70,7 +70,7 @@ const headlessFlag = Flag.Boolean("headless").pipe( * OAuth callback, so the device authorization grant is the only flow that * can work. */ -export const headlessSessionConfig = Config.all({ +const headlessSessionConfig = Config.all({ sshConnection: Config.String("SSH_CONNECTION").pipe(Config.option), sshTty: Config.String("SSH_TTY").pipe(Config.option), }).pipe( @@ -441,7 +441,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f * A missing CLI login or a failed relay DELETE leaves that record registered, so * those outcomes warn to deregister it from the T3 Connect account page. */ -export const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")( +const reportCloudDisconnectResults = Effect.fn("cloud.cli.report_disconnect_results")( function* (input: { readonly clearAuthorization: boolean; readonly liveResult: LiveCloudActionResult; @@ -781,29 +781,29 @@ const connectLogoutCommand = Command.make("logout", { ), ); -export const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")( - function* (outcome: CliService.ServiceOnboardingOutcome) { - if (outcome === "pending") { - yield* Console.log( - "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", - ); - return; - } - if (outcome === "ready") { - const platform = yield* HostProcess.Platform; - yield* Console.log( - platform === "darwin" - ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." - : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", - ); - return; - } - const serveCommand = yield* resolveCliCommand("serve"); +const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onboarding")(function* ( + outcome: CliService.ServiceOnboardingOutcome, +) { + if (outcome === "pending") { yield* Console.log( - `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.\n It checks that other devices can reach this machine when it starts.`, + "\nBackground service restarting\n\nThe server establishes the T3 Connect link once it is back. Check the log above if it does not come back.", ); - }, -); + return; + } + if (outcome === "ready") { + const platform = yield* HostProcess.Platform; + yield* Console.log( + platform === "darwin" + ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." + : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", + ); + return; + } + const serveCommand = yield* resolveCliCommand("serve"); + yield* Console.log( + `\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.\n It checks that other devices can reach this machine when it starts.`, + ); +}); export const connectCommand = Command.make("connect", { ...projectLocationFlags, diff --git a/apps/server/src/cli/consoleStdin.test.ts b/apps/server/src/cli/consoleStdin.test.ts deleted file mode 100644 index 7770fd67d7b8..000000000000 --- a/apps/server/src/cli/consoleStdin.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { needsConsoleStdin } from "./consoleStdin.ts"; - -const launcher = { - platform: "win32" as const, - env: { T3CODE_DESKTOP_CLI_LAUNCHER: "1" }, - stdinOpen: false, - stdoutIsConsole: true, -}; - -describe("needsConsoleStdin", () => { - it("reopens the console for the desktop launcher in a terminal without stdin", () => { - expect(needsConsoleStdin(launcher)).toBe(true); - }); - - it("leaves every other way of running the CLI alone", () => { - for (const other of [ - // Input the user piped or redirected. - { ...launcher, stdinOpen: true }, - // Output to a pipe or file: the desktop backend, scripts, CI. - { ...launcher, stdoutIsConsole: false }, - // The desktop backend runs the same executable but is not the launcher. - { ...launcher, env: { ELECTRON_RUN_AS_NODE: "1", T3CODE_CLI_PATH: "C:\\t3.cmd" } }, - // npm, the install scripts, and plain Node runs. - { ...launcher, env: {} }, - { ...launcher, platform: "linux" as const }, - { ...launcher, platform: "darwin" as const }, - ]) { - expect(needsConsoleStdin(other)).toBe(false); - } - }); -}); diff --git a/apps/server/src/cli/consoleStdin.ts b/apps/server/src/cli/consoleStdin.ts index 240ce8b6567a..f7d7bcc809ea 100644 --- a/apps/server/src/cli/consoleStdin.ts +++ b/apps/server/src/cli/consoleStdin.ts @@ -14,7 +14,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; * redirected is a valid descriptor and is left alone, as is any run whose * output is not a console (the desktop backend, scripts). */ -export function needsConsoleStdin(input: { +function needsConsoleStdin(input: { readonly platform: NodeJS.Platform; readonly env: NodeJS.ProcessEnv; readonly stdinOpen: boolean; diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 54b5124feb0b..1461d1d64b35 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,98 +7,11 @@ import * as Path from "effect/Path"; import packageJson from "../../package.json" with { type: "json" }; import { - formatCliCommand, resolveHostCliCommand, resolveRootCliCommand, resolveServerInstallation, } from "./invocation.ts"; -it("formats package runner commands from their cache entry paths", () => { - for (const [entryPath, expected] of [ - ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], - [ - "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "npx t3 serve", - ], - ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], - [ - "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", - "pnpm dlx t3 serve", - ], - [ - "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - "pnpm dlx t3 serve", - ], - ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], - ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], - [ - "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - "bunx t3 serve", - ], - ] as const) { - assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); - } -}); - -it("treats stable installs as direct invocations", () => { - for (const entryPath of [ - "/usr/local/lib/node_modules/t3/dist/bin.mjs", - "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", - "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", - "", - ]) { - assert.equal( - formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), - "t3 serve", - ); - } -}); - -it("re-suggests the prerelease channel only for prerelease builds", () => { - for (const [version, expected] of [ - ["0.0.31-nightly.20260729", "npx t3@nightly serve"], - ["0.0.31-preview.20260729.1", "npx t3@preview serve"], - ["0.0.31-foo-preview.20260729.1", "npx t3 serve"], - ["0.0.31", "npx t3 serve"], - ] as const) { - assert.equal( - formatCliCommand({ - subcommand: "serve", - entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", - version, - }), - expected, - ); - } -}); - -it("formats serve suggestions to match the launching command", () => { - assert.equal( - formatCliCommand({ - subcommand: "serve", - entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs", - version: "0.0.31-nightly.20260729", - }), - "npx t3@nightly serve", - ); - assert.equal( - formatCliCommand({ - subcommand: "serve", - entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", - version: "0.0.31", - }), - "bunx t3 serve", - ); - assert.equal( - formatCliCommand({ - subcommand: "serve", - entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs", - version: "0.0.31-nightly.20260729", - }), - "t3 serve", - ); -}); - it.layer(NodeServices.layer)("root CLI commands", (it) => { const rootCommand = (input: { readonly node: string; diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index 4be566c10e99..64a2b099e139 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -110,7 +110,7 @@ function suggestedPackageSpec(version: string): string { return channel === undefined ? "t3" : `t3@${channel}`; } -export function formatCliCommand(input: { +function formatCliCommand(input: { readonly subcommand: string; readonly entryPath: string; readonly version: string; diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 40855f32546c..1eeeee4aaba2 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -29,11 +29,7 @@ import { persistServerRuntimeState, type PersistedServerRuntimeState, } from "../serverRuntimeState.ts"; -import { - DevServerNotProxiableError, - resolveDirectPairingBaseUrl, - resolveTailscaleLocalTarget, -} from "./pair.ts"; +import { DevServerNotProxiableError, resolveTailscaleLocalTarget } from "./pair.ts"; import packageJson from "../../package.json" with { type: "json" }; @@ -47,21 +43,6 @@ const baseState = { startedAt: "2026-06-20T00:00:00.000Z", } as const satisfies PersistedServerRuntimeState; -describe("pair base URL selection", () => { - it("pairs through the dev web origin when the server fronts a dev server", () => { - expect(resolveDirectPairingBaseUrl({ ...baseState, devUrl: "http://localhost:5733/" })).toBe( - "http://localhost:5733/", - ); - }); - - it("pairs through the bound host when there is no dev server", () => { - expect(resolveDirectPairingBaseUrl({ ...baseState, host: "100.64.0.7" })).toBe( - "http://100.64.0.7:3773", - ); - expect(resolveDirectPairingBaseUrl(baseState)).toBe("http://localhost:3773"); - }); -}); - describe("pair tailscale local target", () => { it("proxies the dev web port for dev servers", () => { expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual( @@ -83,17 +64,6 @@ describe("pair tailscale local target", () => { resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }), ).toBeInstanceOf(DevServerNotProxiableError); }); - - it("proxies the backend port directly otherwise", () => { - expect(resolveTailscaleLocalTarget(baseState)).toEqual({ localPort: 3_773 }); - expect(resolveTailscaleLocalTarget({ ...baseState, host: "0.0.0.0" })).toEqual({ - localPort: 3_773, - }); - expect(resolveTailscaleLocalTarget({ ...baseState, host: "192.168.1.42" })).toEqual({ - localPort: 3_773, - localHost: "192.168.1.42", - }); - }); }); const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); @@ -270,43 +240,6 @@ describe("t3 pair", () => { ), ); - it.effect("mints a pairing grant with only the selected scopes", () => - withDescriptorServer((origin) => - Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-scopes-test-")); - yield* persistServerRuntimeState({ - path: NodePath.join(baseDir, "userdata", "server-runtime.json"), - state: yield* makePersistedServerRuntimeState({ - config: { host: "127.0.0.1", devUrl: undefined }, - port: Number(new URL(origin).port), - }), - }); - - yield* captureStdout( - runCli([ - "pair", - "--base-dir", - baseDir, - "--scope", - "orchestration:read", - "--scope", - "relay:read", - "--scope", - "orchestration:read", - ]), - ); - const listed = yield* captureStdout( - runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), - ); - const credentials = JSON.parse(listed) as ReadonlyArray<{ - readonly scopes: ReadonlyArray; - }>; - assert.lengthOf(credentials, 1); - assert.deepEqual(credentials[0]?.scopes, ["orchestration:read", "relay:read"]); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("pairs through the recorded dev web URL for dev servers", () => withDescriptorServer((origin) => Effect.gen(function* () { @@ -328,23 +261,6 @@ describe("t3 pair", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("directs to t3 serve or t3 connect when no server is running", () => - Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-none-test-")); - - const error = yield* provideCliTestLayers( - runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), - ); - - const rendered = String( - typeof error === "object" && error !== null && "cause" in error ? error.cause : error, - ); - assert.include(rendered, "No running T3 Code server found."); - assert.include(rendered, "npx t3 serve"); - assert.include(rendered, "npx t3 connect"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("ignores runtime state whose recorded pid is no longer alive", () => withDescriptorServer((origin) => Effect.gen(function* () { @@ -371,29 +287,6 @@ describe("t3 pair", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("ignores stale runtime state pointing at a dead server", () => - Effect.gen(function* () { - const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-stale-test-")); - const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); - yield* persistServerRuntimeState({ - path: statePath, - state: yield* makePersistedServerRuntimeState({ - config: { host: "127.0.0.1", devUrl: undefined }, - port: 1, - }), - }); - - const error = yield* provideCliTestLayers( - runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), - ); - - const rendered = String( - typeof error === "object" && error !== null && "cause" in error ? error.cause : error, - ); - assert.include(rendered, "No running T3 Code server found."); - }).pipe(Effect.provide(NodeServices.layer)), - ); - // Without the capability `tailscale serve` blocks on an enablement URL, so // pairing must stop at status instead of spawning it. it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () => diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 6abe38de8f95..4522258c2712 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -126,7 +126,7 @@ export class ServePortOccupiedError extends Schema.TaggedError +const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string => state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); export class DevServerNotProxiableError extends Schema.TaggedError()( diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index fb7a386059d6..40a7ab711383 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -9,7 +9,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CommandId, - EnvironmentInternalError, EventId, ProviderInstanceId, ThreadId, @@ -40,11 +39,6 @@ import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolv import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; -import { - ProjectLiveServerDeclaredResponseError, - ProjectLiveServerRequestError, - projectCommandErrorFromLiveServerRequest, -} from "./project.ts"; const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NetService.layer); const runCli = (args: ReadonlyArray) => @@ -107,34 +101,6 @@ const readProjects = (baseDir: string) => ); }); -it("maps declared server failures into structural project command errors", () => { - const cause = new EnvironmentInternalError({ - code: "internal_error", - reason: "access_token_issuance_failed", - traceId: "trace-123", - }); - - const error = projectCommandErrorFromLiveServerRequest(cause); - - assert.instanceOf(error, ProjectLiveServerDeclaredResponseError); - assert.strictEqual(error.operation, "callLiveServer"); - assert.strictEqual(error.code, "internal_error"); - assert.strictEqual(error.traceId, "trace-123"); - assert.strictEqual(error.message, "Server request failed (internal_error, trace trace-123)."); - assert.strictEqual(error.cause, cause); -}); - -it("preserves unexpected server failures without deriving the message from them", () => { - const cause = new Error("credential abc123 was rejected"); - - const error = projectCommandErrorFromLiveServerRequest(cause); - - assert.instanceOf(error, ProjectLiveServerRequestError); - assert.strictEqual(error.operation, "callLiveServer"); - assert.strictEqual(error.message, "Failed to call the running server."); - assert.strictEqual(error.cause, cause); -}); - it.effect("adds, renames, and removes projects through the V2 project CLI domain", () => Effect.gen(function* () { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-project-cli-")); @@ -253,12 +219,6 @@ const readNativeThreadState = Effect.fn("ProjectCliTest.readNativeThreadState")( it.layer(NodeServices.layer)("project deletion with native V2 threads", (it) => { it.effect.each([ { label: "an active thread", archived: false, missing: false }, - { label: "an archived thread", archived: true, missing: false }, - { - label: "an active thread after its workspace disappears", - archived: false, - missing: true, - }, { label: "an archived thread after its workspace disappears", archived: true, @@ -370,22 +330,6 @@ it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) }), ); - it.effect("renames by ID and stored path after the directory is gone", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); - yield* fs.rename(workspaceRoot, `${workspaceRoot}-removed`); - for (const [identifier, title] of [ - [project.id, "Renamed by ID"], - [workspaceRoot, "Renamed by stored path"], - ] as const) { - yield* runCli(["project", "rename", identifier, title, "--base-dir", baseDir]); - assert.equal((yield* readProjects(baseDir)).projects[0]?.title, title); - } - assert.isFalse(yield* fs.exists(workspaceRoot)); - }), - ); - it.effect("does not resolve another environment's project ID in an empty database", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index b567a7d4d3d0..a5c8aa532ff3 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -169,7 +169,7 @@ export const ProjectCommandError = Schema.Union([ ]); export type ProjectCommandError = typeof ProjectCommandError.Type; -export function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError { +function projectCommandErrorFromLiveServerRequest(cause: unknown): ProjectCommandError { if (isEnvironmentHttpCommonError(cause)) { return new ProjectLiveServerDeclaredResponseError({ operation: "callLiveServer", diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index dbd0653647ba..904fb87a1c6b 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -1,9 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; import * as ConfigProvider from "effect/ConfigProvider"; -import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -14,9 +13,7 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { - formatServiceStatus, offerServiceDuringOnboarding, - reconcileService, recoverServiceOnboardingOffer, serviceCommand, } from "./service.ts"; @@ -31,71 +28,6 @@ const status = { logPath: "/home/me/.t3/userdata/logs/boot-service.log", } as const; -it("reports the installed service version and host paths", () => { - assert.equal( - formatServiceStatus(status, "0.0.29"), - [ - "T3 Code service", - " Status: installed · t3@0.0.29", - " Unit: /home/me/.config/systemd/user/t3code.service", - " Logs: /home/me/.t3/userdata/logs/boot-service.log", - ].join("\n"), - ); -}); - -it("gives a direct repair command for a stale service", () => { - assert.include( - formatServiceStatus({ ...status, current: false }, "0.0.29"), - "Next: Run `t3 service install` to repair it.", - ); -}); - -it("explains an incomplete nightly installation and keeps repair on its installed version", () => { - const output = formatServiceStatus( - { - ...status, - current: false, - installedVersion: "0.0.32-nightly.1", - problems: ["linger-disabled", "service-stopped"], - }, - "0.0.32-nightly.1", - ); - - expect(output).toContain("[linger-disabled]"); - expect(output).toContain("last login session ends"); - expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); - expect(output).toContain("[service-stopped]"); - expect(output).toContain("Run `t3 service install` to repair it."); - expect(output).not.toContain("npx"); -}); - -it("points an older service at a repair, never at npx", () => { - const output = formatServiceStatus( - { ...status, current: false, installedVersion: "0.0.28" }, - "0.0.29", - ); - expect(output).toContain("Run `t3 service install` to repair it."); - expect(output).not.toContain("npx"); -}); - -it("explains where the service is supported", () => { - assert.include( - formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), - "Supported on: Linux with systemd, macOS with launchd", - ); -}); - -it("reports a newer installed service and tells the CLI to catch up to it", () => { - const output = formatServiceStatus( - { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, - "0.0.31", - ); - - assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); - assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it"); - assert.notInclude(output, "npx"); -}); - const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; function makeTestService( @@ -127,97 +59,6 @@ function makeTestService( } it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { - it.effect("restart restarts the installed service", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service, installOptions, restarts } = makeTestService(status); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, service), - ); - - yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - "restart", - "--base-dir", - baseDir, - ]).pipe( - Effect.provideService(HostProcess.Environment, {}), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), - ); - - expect(restarts).toEqual([true]); - expect(installOptions).toEqual([]); - }), - ); - - it.effect("restart from inside the service says the restart is only queued", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service } = makeTestService({ ...status, installedVersion: "0.0.30" }, "queued"); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, service), - ); - const lines: Array = []; - const testConsole = { - ...globalThis.console, - log: (...args: ReadonlyArray) => { - lines.push(args.map(String).join(" ")); - }, - } satisfies Console.Console; - - yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - "restart", - "--base-dir", - baseDir, - ]).pipe( - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(Console.Console, testConsole), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), - ); - - const output = lines.join("\n"); - expect(output).not.toContain("Restarted"); - expect(output).toContain("Queued a restart of the T3 Code service on t3@0.0.30"); - expect(output).toContain(`Logs: ${status.logPath}`); - }), - ); - - it.effect("install reports a handed-off switch as pending", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service } = makeTestService({ ...status, current: false }); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, { - ...service, - install: (options) => - service.install(options).pipe(Effect.map((plan) => ({ ...plan, handedOff: true }))), - }), - ); - const lines: string[] = []; - yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - "install", - "--base-dir", - baseDir, - ]).pipe( - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(Console.Console, { - ...globalThis.console, - log: (...args) => { - lines.push(args.map(String).join(" ")); - }, - }), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), - ); - - const output = lines.join("\n"); - expect(output).not.toMatch(/(?:Installed|Updated) T3 Code service/); - expect(output).toContain("separate systemd unit"); - expect(output).toContain(status.logPath); - }), - ); - it.effect.each(["install", "update"] as const)( "%s refuses a downgrade before changing the service", (command) => @@ -247,61 +88,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands expect(installOptions).toEqual([]); }), ); - - it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); - const { service, installOptions } = makeTestService(newerServiceStatus); - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, service), - ); - - yield* Command.runWith(serviceCommand, { version: packageJson.version })([ - command, - "--base-dir", - baseDir, - "--allow-downgrade", - ]).pipe( - Effect.provideService(HostProcess.Environment, {}), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), - ); - - expect(installOptions).toEqual([{ allowDowngrade: true }]); - }), - ); }); -it.effect.each([ - { name: "a new service", state: { ...status, installed: false, current: false } }, - { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } }, - { - name: "the same version", - state: { ...status, current: false, installedVersion: packageJson.version }, - }, - { - name: "an incomplete install of the same version", - state: { - ...status, - current: false, - installedVersion: packageJson.version, - problems: ["linger-disabled"] as const, - }, - }, - { name: "an unknown version", state: { ...status, current: false } }, -])("installs or repairs $name without an override", ({ state }) => - Effect.gen(function* () { - const { service, installOptions } = makeTestService(state); - - const result = yield* reconcileService().pipe( - Effect.provideService(BootService.BootService, service), - ); - - expect(result.changed).toBe(true); - expect(installOptions).toEqual([undefined]); - }), -); - it.effect("leaves a newer service unchanged during onboarding without prompting", () => Effect.gen(function* () { const { service, installOptions } = makeTestService(newerServiceStatus); @@ -324,21 +112,7 @@ it.effect("leaves a newer service unchanged during onboarding without prompting" }), ); -it.effect("keeps onboarding successful when a newer version appears before install", () => - Effect.gen(function* () { - const ready = yield* recoverServiceOnboardingOffer( - Effect.fail( - new BootService.BootServiceDowngradeRefusedError({ - installedVersion: "999.0.0", - targetVersion: packageJson.version, - }), - ), - ); - - expect(ready).toBe("skipped"); - }), -); - +// regression: #9602 diagnose incomplete headless server setup it.effect("keeps the manual-server fallback when background prerequisites fail", () => Effect.gen(function* () { const ready = yield* recoverServiceOnboardingOffer( diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 352fa86756c9..5c74b65b12b6 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -31,7 +31,7 @@ export type ServiceReconcileResult = readonly plan: BootService.BootServicePlan; }; -export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { +const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { readonly allowDowngrade?: boolean; readonly start?: boolean; }) { @@ -67,10 +67,7 @@ function formatServiceChange( return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; } -export function formatServiceStatus( - status: BootService.BootServiceStatus, - cliVersion: string, -): string { +function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: string): string { if (!status.supported) { return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; } diff --git a/apps/server/src/cli/sshHelper.test.ts b/apps/server/src/cli/sshHelper.test.ts deleted file mode 100644 index 54ec1a2f2a87..000000000000 --- a/apps/server/src/cli/sshHelper.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as NodeNet from "node:net"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/process"; - -// On macOS, Node can bind 127.0.0.1: while another process (such as the -// desktop app with network access on) listens on *:. Linux refuses that -// bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to -// the picker's probe and the holder sets it too. macOS needs neither. -const MACOS_BIND_PRELOAD = `const net = require("node:net"); -const listen = net.Server.prototype.listen; -net.Server.prototype.listen = function (port, host, callback) { - if (typeof port !== "number" || typeof host !== "string") return listen.apply(this, arguments); - return listen.call(this, { port, host, reusePort: true }, callback); -}; -`; - -describe("__ssh-helper pick-port", () => { - const hostPlatform = HostProcess.Platform.defaultValue(); - const linuxHost = hostPlatform === "linux"; - const windowsHost = hostPlatform === "win32"; - - const holdWildcardPort = Effect.acquireRelease( - Effect.callback((resume) => { - const server = NodeNet.createServer((socket) => socket.destroy()); - server.once("error", (error) => resume(Effect.die(error))); - server.listen({ port: 0, host: "0.0.0.0", reusePort: linuxHost }, () => - resume(Effect.succeed(server)), - ); - }), - (server) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - }), - ); - - it.effect.skipIf(windowsHost)( - "skips a port another server answers on loopback", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pick-port-" }); - yield* fs.writeFileString(`${root}/preload.cjs`, MACOS_BIND_PRELOAD); - const holder = yield* holdWildcardPort; - const heldPort = (holder.address() as NodeNet.AddressInfo).port; - - const child = yield* spawner.spawn( - ChildProcess.make(process.execPath, [ - ...(linuxHost ? ["--require", `${root}/preload.cjs`] : []), - "--experimental-strip-types", - new URL("../bin.ts", import.meta.url).pathname, - "__ssh-helper", - "pick-port", - "", - String(heldPort), - "20", - ]), - ); - const [stdout, exitCode] = yield* Effect.all( - [ - child.stdout.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - assert.equal(exitCode, 0); - const picked = Number(stdout); - assert.isAbove(picked, heldPort); - assert.isBelow(picked, heldPort + 20); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - 30_000, - ); -}); diff --git a/apps/server/src/cli/theme.test.ts b/apps/server/src/cli/theme.test.ts index c14e97a53c1c..f28548fe0bc9 100644 --- a/apps/server/src/cli/theme.test.ts +++ b/apps/server/src/cli/theme.test.ts @@ -4,7 +4,6 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as NetService from "@t3tools/shared/Net"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -48,14 +47,6 @@ const writeSettings = (baseDir: string, settings: Record) => { }; describe("t3 theme", () => { - it.effect("writes a default theme when no settings file exists yet", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); - assert.equal(readSettings(baseDir).defaultTheme, "ocean"); - }), - ); - it.effect("preserves settings it does not recognise", () => Effect.gen(function* () { const baseDir = makeBaseDir(); @@ -105,22 +96,6 @@ describe("t3 theme", () => { }), ); - it.effect("publishes a theme file under an explicit id", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const themeFile = NodePath.join(baseDir, "t3code.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]); - - assert.equal( - NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), - true, - ); - assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); - }), - ); - it.effect("rejects a file that is not a theme and sets nothing", () => Effect.gen(function* () { const baseDir = makeBaseDir(); @@ -137,66 +112,6 @@ describe("t3 theme", () => { }), ); - it.effect("publishes nothing when the settings file cannot be used", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); - NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json"); - const themeFile = NodePath.join(baseDir, "nightfall.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( - Effect.flip, - ); - - assert.include(String(failure), "not a JSON object"); - assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); - }), - ); - - it.effect.skipIf(windowsHost)("rolls back a publish when the default cannot be written", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - writeSettings(baseDir, {}); - const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); - const themesDir = NodePath.join(userdataDir, "themes"); - NodeFS.mkdirSync(themesDir, { recursive: true }); - const themeFile = NodePath.join(baseDir, "nightfall.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - NodeFS.chmodSync(userdataDir, 0o555); - try { - const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "Could not write"); - assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false); - } finally { - NodeFS.chmodSync(userdataDir, 0o755); - } - }), - ); - - it.effect.skipIf(!symlinksSupported)( - "publishes a theme file through a symlinked source path", - () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const realFile = NodePath.join(baseDir, "real-nightfall.json"); - NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); - const linkPath = NodePath.join(baseDir, "nightfall.json"); - NodeFS.symlinkSync(realFile, linkPath); - - yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); - - assert.equal( - NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), - true, - ); - assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); - }), - ); - it.effect.skipIf(!symlinksSupported)("never writes through a symlink at the staging path", () => Effect.gen(function* () { const baseDir = makeBaseDir(); @@ -215,32 +130,6 @@ describe("t3 theme", () => { }), ); - it.effect.skipIf(!symlinksSupported || windowsHost)( - "restores a non-theme destination entry when the set fails", - () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - writeSettings(baseDir, {}); - const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); - const themesDir = NodePath.join(userdataDir, "themes"); - NodeFS.mkdirSync(themesDir, { recursive: true }); - const outside = NodePath.join(baseDir, "outside.json"); - NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); - const destination = NodePath.join(themesDir, "nightfall.json"); - NodeFS.symlinkSync(outside, destination); - const themeFile = NodePath.join(baseDir, "nightfall.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - NodeFS.chmodSync(userdataDir, 0o555); - try { - yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); - assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); - } finally { - NodeFS.chmodSync(userdataDir, 0o755); - } - }), - ); - it.effect.skipIf(windowsHost)("restores the previous theme when a re-publish fails to set", () => Effect.gen(function* () { const baseDir = makeBaseDir(); @@ -265,73 +154,6 @@ describe("t3 theme", () => { }), ); - it.effect("rejects an id no client could resolve", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "not a valid theme id"); - assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); - }), - ); - - it.effect("rejects a path that does not exist instead of storing it as an id", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const failure = yield* runCli([ - "theme", - "set", - `${baseDir}/missing.json`, - "--base-dir", - baseDir, - ]).pipe(Effect.flip); - assert.include(String(failure), "Could not read"); - }), - ); - - it.effect("publishes an extensionless file", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const themeFile = NodePath.join(baseDir, "brand"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - - yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); - - assert.equal( - NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")), - true, - ); - assert.equal(readSettings(baseDir).defaultTheme, "brand"); - }), - ); - - it.effect("records a set generation and clears it with the theme", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); - const setAt = readSettings(baseDir).defaultThemeSetAt; - assert.equal(typeof setAt, "string"); - - yield* runCli(["theme", "clear", "--base-dir", baseDir]); - const cleared = readSettings(baseDir); - assert.equal(Object.hasOwn(cleared, "defaultTheme"), false); - assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false); - }), - ); - - it.effect("honors T3CODE_HOME like the rest of the CLI", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - yield* runCli(["theme", "set", "ocean"]).pipe( - Effect.provide( - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })), - ), - ); - assert.equal(readSettings(baseDir).defaultTheme, "ocean"); - }), - ); - it.effect.skipIf(windowsHost)("refuses to write when the settings file cannot be read", () => Effect.gen(function* () { const baseDir = makeBaseDir(); @@ -348,105 +170,4 @@ describe("t3 theme", () => { assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false); }), ); - - it.effect("rejects an id that names no theme", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "No theme named"); - assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); - }), - ); - - it.effect("accepts an id a published file provides", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const themeFile = NodePath.join(baseDir, "nightfall.json"); - NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); - yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); - - yield* runCli(["theme", "clear", "--base-dir", baseDir]); - yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]); - assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); - }), - ); - - it.effect("rejects an id whose published file the watcher would skip", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const themesDir = NodePath.join(baseDir, "userdata", "themes"); - NodeFS.mkdirSync(themesDir, { recursive: true }); - NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n"); - - const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "No theme named"); - }), - ); - - it.effect("rejects the mobile default theme id", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "No theme named"); - }), - ); - - it.effect("treats a bare id as an id even when a file shares its name", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const cwdFile = NodePath.join(baseDir, "ocean"); - NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON); - - const previous = process.cwd(); - process.chdir(baseDir); - try { - yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); - } finally { - process.chdir(previous); - } - - assert.equal(readSettings(baseDir).defaultTheme, "ocean"); - assert.equal( - NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")), - false, - ); - }), - ); - - it.effect("rejects a theme file larger than the watcher will read", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - const themeFile = NodePath.join(baseDir, "huge.json"); - const padding = "x".repeat(40 * 1024); - NodeFS.writeFileSync( - themeFile, - `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`, - ); - - const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( - Effect.flip, - ); - assert.include(String(failure), "larger than"); - }), - ); - - it.effect("refuses a settings file that is not a JSON object", () => - Effect.gen(function* () { - const baseDir = makeBaseDir(); - NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); - NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n"); - - const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( - Effect.flip, - ); - - assert.include(String(failure), "not a JSON object"); - }), - ); }); diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts new file mode 100644 index 000000000000..c2e4bc5c8168 --- /dev/null +++ b/apps/server/src/cli/thread.test.ts @@ -0,0 +1,75 @@ +import { assert, it } from "@effect/vitest"; +import { RunId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { type ThreadTools, waitForRun } from "./thread.ts"; + +const threadId = ThreadId.make("thread-1"); +const runId = RunId.make("run-1"); + +/** Answers each tool call with the next scripted response for that tool. */ +const scriptedTools = (script: Record>) => { + const calls: Array<{ readonly tool: string; readonly args: unknown }> = []; + const call = ((tool: string, args: unknown) => { + calls.push({ tool, args }); + const next = script[tool]?.shift(); + return next === undefined ? Effect.die(`unscripted ${tool} call`) : Effect.succeed(next); + }) as ThreadTools["call"]; + return { tools: { call } satisfies ThreadTools, calls }; +}; + +it.effect("--wait follows the sent run to its continuation's terminal status", () => + Effect.gen(function* () { + const continuation = RunId.make("run-2"); + const { tools, calls } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId: continuation, status: "failed", timedOut: false }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 0 } }], + }); + + const outcome = yield* waitForRun(tools, threadId, runId); + + assert.deepStrictEqual(outcome, { type: "ended", runId: continuation, status: "failed" }); + assert.deepStrictEqual( + calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), + [ + { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000 }, + ], + ); + }), +); + +it.effect("--wait stops when the thread waits for an answer instead of polling forever", () => + Effect.gen(function* () { + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "queued", timedOut: true }, + { threadId, runId, status: "queued", timedOut: true }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], + }); + + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" }); + }), +); + +it.effect("--wait reports its run's result when a later run is the one waiting", () => + Effect.gen(function* () { + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId, status: "completed", timedOut: false }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], + }); + + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { + type: "ended", + runId, + status: "completed", + }); + }), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts new file mode 100644 index 000000000000..989028afccb9 --- /dev/null +++ b/apps/server/src/cli/thread.ts @@ -0,0 +1,710 @@ +import { + OrchestratorMcpFailure, + OrchestratorMcpThreadListResult, + OrchestratorMcpThreadReadResult, + OrchestratorMcpThreadSendResult, + OrchestratorMcpThreadWaitResult, + ProjectId, + RunId, + ThreadId, + TrimmedNonEmptyString, + type OrchestratorMcpThreadListItem, + type OrchestratorMcpThreadStatus, + type OrchestratorMcpThreadTimelineItem, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Runtime from "effect/Runtime"; +import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; +import * as Stream from "effect/Stream"; +import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +/** + * Short enough that a run which stops to ask the user something is noticed + * soon, since `t3_thread_wait` only returns on a terminal run. + */ +const WAIT_SLICE_MS = 15_000; +const READ_PAGE_LIMIT = 100; +const TITLE_MAX_CHARS = 80; + +export class ThreadServerNotRunningError extends Schema.TaggedError()( + "ThreadServerNotRunningError", + { baseDir: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return `No running T3 Code server uses ${this.baseDir}. Start T3 Code, then retry.`; + } +} + +export class ThreadServerUnreachableError extends Schema.TaggedError()( + "ThreadServerUnreachableError", + { origin: Schema.String, cause: Schema.Defect() }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + const reason = this.cause instanceof Error ? ` ${this.cause.message}` : ""; + return `Could not reach the T3 Code server at ${this.origin}.${reason}`; + } +} + +export class ThreadToolFailedError extends Schema.TaggedError()( + "ThreadToolFailedError", + { tool: Schema.String, detail: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return this.detail; + } +} + +export class ThreadUsageError extends Schema.TaggedError()("ThreadUsageError", { + detail: Schema.String, +}) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return this.detail; + } +} + +export class ThreadProjectNotFoundError extends Schema.TaggedError()( + "ThreadProjectNotFoundError", + { identifier: Schema.String }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + return `No T3 Code project matches '${this.identifier}'. Pass a project id or a path inside a project.`; + } +} + +/** Exit code 2 lets a script tell "needs a human" apart from a failed run. */ +export class ThreadNeedsInputError extends Schema.TaggedError()( + "ThreadNeedsInputError", + { threadId: ThreadId }, +) { + override readonly [Runtime.errorReported] = false; + override readonly [Runtime.errorExitCode] = 2; + + override get message(): string { + return `Thread ${this.threadId} is waiting for an answer. Open it in T3 Code to respond.`; + } +} + +export class ThreadRunEndedError extends Schema.TaggedError()( + "ThreadRunEndedError", + { threadId: ThreadId, status: Schema.String, error: Schema.optional(Schema.String) }, +) { + override readonly [Runtime.errorReported] = false; + override get message(): string { + const reason = this.error === undefined ? "." : `: ${this.error}`; + return `Thread ${this.threadId} stopped with status ${this.status}${reason}`; + } +} + +const ThreadCliError = Schema.Union([ + ThreadServerNotRunningError, + ThreadServerUnreachableError, + ThreadToolFailedError, + ThreadUsageError, + ThreadProjectNotFoundError, + ThreadNeedsInputError, + ThreadRunEndedError, +]); +const isThreadCliError = Schema.is(ThreadCliError); + +/** + * These errors are expected outcomes for a script, so they print as one line + * on stderr instead of the runtime's logged stack trace. + */ +const reportThreadErrors = (effect: Effect.Effect) => + effect.pipe( + Effect.tapError((error) => + isThreadCliError(error) ? Console.error(error.message) : Effect.void, + ), + ); + +const decodeFailureText = Schema.decodeUnknownOption(Schema.fromJsonString(OrchestratorMcpFailure)); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const printJson = (value: unknown) => Console.log(encodeJson(value)); + +const ToolResult = Schema.Struct({ + isError: Schema.optional(Schema.Boolean), + structuredContent: Schema.optional(Schema.Unknown), + content: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), +}); + +const decodeToolResult = Schema.decodeUnknownEffect(ToolResult); + +const ProjectListResult = Schema.Struct({ + projects: Schema.Array( + Schema.Struct({ id: ProjectId, title: Schema.String, workspaceRoot: Schema.String }), + ), + nextCursor: Schema.NullOr(Schema.Int), +}); + +const ThreadLaunchResult = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + runId: Schema.NullOr(RunId), +}); + +const toolFailureDetail = (result: typeof ToolResult.Type) => { + const text = result.content?.find((part) => part.text !== undefined)?.text ?? ""; + return Option.match(decodeFailureText(text), { + onNone: () => text || "The tool call failed.", + onSome: (failure) => failure.message, + }); +}; + +const makeThreadTools = (session: McpToolSession, origin: string) => { + const unexpected = (tool: string) => + new ThreadToolFailedError({ + tool, + detail: `The server sent an unexpected ${tool} result. Update the t3 CLI to match the server.`, + }); + return { + call: Effect.fn("cli.thread.callTool")(function* >( + tool: string, + args: Readonly>, + schema: S, + ): Effect.fn.Return { + const raw = yield* session + .call(tool, args) + .pipe(Effect.mapError((cause) => new ThreadServerUnreachableError({ origin, cause }))); + const result = yield* decodeToolResult(raw).pipe(Effect.mapError(() => unexpected(tool))); + if (result.isError === true) { + return yield* new ThreadToolFailedError({ tool, detail: toolFailureDetail(result) }); + } + return yield* Schema.decodeUnknownEffect(schema)(result.structuredContent).pipe( + Effect.mapError(() => unexpected(tool)), + ); + }), + }; +}; +export type ThreadTools = ReturnType; + +/** + * Signs in as a short-lived MCP client of the running server, the same way an + * outside agent does, and revokes that session when the command ends. + */ +const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* ( + flags: { readonly baseDir: Option.Option }, + run: (tools: ThreadTools) => Effect.Effect, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* Effect.gen(function* () { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return yield* new ThreadServerNotRunningError({ baseDir: config.baseDir }); + } + const origin = runtimeState.value.origin; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const issued = yield* Effect.acquireRelease( + environmentAuth.issueMcpClientSession({ + label: "t3 thread cli", + // The CLI runs as the machine's owner. New threads still start at + // the project's default mode, never at this ceiling by default. + access: "full-access", + client: { deviceType: "bot" }, + }), + (session) => + environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore({ log: true })), + ); + const session = yield* mcpToolSession({ + endpoint: new URL("/mcp", origin).href, + authorization: `Bearer ${issued.token}`, + clientName: "t3-thread-cli", + }).pipe(Effect.mapError((cause) => new ThreadServerUnreachableError({ origin, cause }))); + return yield* run(makeThreadTools(session, origin)); + }).pipe( + Effect.scoped, + Effect.provide( + EnvironmentAuth.layerRuntime.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), + ), + ), + ); +}); + +const readMessageArgument = Effect.fn("cli.thread.readMessage")(function* (argument: string) { + const text = + argument === "-" + ? yield* Stdio.Stdio.pipe( + Effect.flatMap((stdio) => stdio.stdin.pipe(Stream.decodeText(), Stream.mkString)), + ) + : argument; + if (text.trim().length === 0) { + return yield* new ThreadUsageError({ detail: "The message is empty." }); + } + return text; +}); + +const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools: ThreadTools) { + const projects: Array<(typeof ProjectListResult.Type)["projects"][number]> = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page: typeof ProjectListResult.Type = yield* tools.call( + "t3_project_list", + { cursor, limit: 100 }, + ProjectListResult, + ); + projects.push(...page.projects); + cursor = page.nextCursor; + } + return projects; +}); + +/** A project id, or a path inside a project's folder (deepest project wins). */ +const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( + tools: ThreadTools, + identifier: string, +) { + const projects = yield* listAllProjects(tools); + const byId = projects.find((project) => project.id === identifier); + if (byId !== undefined) return byId; + + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + // Stored roots keep the path the project was added with, which may pass + // through a symlink (macOS /tmp), so both sides compare as real paths. + const realPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + const target = yield* realPath(path.resolve(yield* HostProcess.WorkingDirectory, identifier)); + const roots = yield* Effect.forEach(projects, (project) => + Effect.map(realPath(project.workspaceRoot), (root) => ({ project, root })), + ); + const containing = roots + .filter(({ root }) => { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }) + .toSorted((left, right) => right.root.length - left.root.length)[0]; + if (containing === undefined) { + return yield* new ThreadProjectNotFoundError({ identifier }); + } + return containing.project; +}); + +/** Every item on a timeline view, oldest first. */ +const readTimeline = Effect.fn("cli.thread.readTimeline")(function* ( + tools: ThreadTools, + threadId: ThreadId, + view: "messages" | "activity", +) { + const items: Array = []; + let afterPosition: number | undefined; + let page: OrchestratorMcpThreadReadResult; + do { + page = yield* tools.call( + "t3_thread_read", + { + threadId, + view, + limit: READ_PAGE_LIMIT, + runLimit: 1, + ...(afterPosition === undefined ? {} : { afterPosition }), + }, + OrchestratorMcpThreadReadResult, + ); + items.push(...page.items); + afterPosition = page.nextPosition ?? undefined; + } while (page.hasMore && afterPosition !== undefined); + return { thread: page.thread, items }; +}); + +/** Item text past the read's per-item cap. */ +const readFullText = Effect.fn("cli.thread.readFullText")(function* ( + tools: ThreadTools, + threadId: ThreadId, + item: OrchestratorMcpThreadTimelineItem, +) { + let text = item.text ?? ""; + let nextTextOffset = item.textTruncated ? (item.nextTextOffset ?? null) : null; + while (nextTextOffset !== null) { + const page = yield* tools.call( + "t3_thread_read", + { threadId, itemId: item.itemId, textOffset: nextTextOffset, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + const chunk = page.items.find((candidate) => candidate.itemId === item.itemId); + if (chunk === undefined) break; + text += chunk.text ?? ""; + nextTextOffset = chunk.textTruncated ? (chunk.nextTextOffset ?? null) : null; + } + return text; +}); + +/** The run's last assistant message, or its last error when it did not complete. */ +const runResultText = Effect.fn("cli.thread.runResultText")(function* ( + tools: ThreadTools, + threadId: ThreadId, + runId: RunId, + type: "assistant_message" | "error", +) { + const { items } = yield* readTimeline( + tools, + threadId, + type === "assistant_message" ? "messages" : "activity", + ); + const item = items.findLast((candidate) => candidate.runId === runId && candidate.type === type); + return item === undefined ? null : yield* readFullText(tools, threadId, item); +}); + +type WaitOutcome = + | { + readonly type: "ended"; + readonly runId: RunId | null; + readonly status: OrchestratorMcpThreadStatus; + } + | { readonly type: "needs_input" }; + +/** + * Waits for this exact run, following a restart to its continuation, so a + * message another client sends meanwhile cannot stand in for its result. + */ +export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( + tools: ThreadTools, + threadId: ThreadId, + runId: RunId, +) { + const waitSlice = (timeoutMs: number) => + tools.call("t3_thread_wait", { threadId, runId, timeoutMs }, OrchestratorMcpThreadWaitResult); + const ended = (waited: OrchestratorMcpThreadWaitResult) => + ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; + while (true) { + const waited = yield* waitSlice(WAIT_SLICE_MS); + if (!waited.timedOut) return ended(waited); + // A pending request on this run, or on an earlier run this one is queued + // behind, blocks it until someone answers. + const { thread } = yield* tools.call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + if (thread.pendingRequestCount > 0) { + // The count is thread-wide: if this run ended since the slice, the + // request belongs to a later run. + const recheck = yield* waitSlice(1); + return recheck.timedOut ? ({ type: "needs_input" } satisfies WaitOutcome) : ended(recheck); + } + } +}); + +/** + * With --json the result always reaches stdout, so a script keeps the thread + * id even when the run fails. + */ +const finishRun = Effect.fn("cli.thread.finishRun")(function* ( + tools: ThreadTools, + input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly json: boolean; + }, +) { + const outcome = yield* waitForRun(tools, input.threadId, input.runId); + const runId = outcome.type === "ended" ? (outcome.runId ?? input.runId) : input.runId; + const status = outcome.type === "ended" ? outcome.status : "waiting"; + const reply = + status === "completed" + ? yield* runResultText(tools, input.threadId, runId, "assistant_message") + : null; + const error = + status === "completed" || status === "waiting" + ? null + : yield* runResultText(tools, input.threadId, runId, "error"); + if (input.json) { + yield* printJson({ threadId: input.threadId, runId, status, reply, error }); + } else if (reply !== null) { + yield* Console.log(reply); + } + if (outcome.type === "needs_input") { + return yield* new ThreadNeedsInputError({ threadId: input.threadId }); + } + if (status !== "completed") { + return yield* new ThreadRunEndedError({ + threadId: input.threadId, + status, + ...(error === null ? {} : { error }), + }); + } +}); + +const titleFromMessage = (message: string) => { + const firstLine = message.trim().split("\n")[0]?.trim() ?? ""; + return firstLine.length > TITLE_MAX_CHARS + ? `${firstLine.slice(0, TITLE_MAX_CHARS - 1).trimEnd()}…` + : firstLine; +}; + +const launchWorkspaceStrategy = (input: { + readonly worktree: Option.Option; + readonly branch: Option.Option; +}) => + Option.match(input.worktree, { + onNone: () => + Option.isSome(input.branch) + ? Effect.fail(new ThreadUsageError({ detail: "--branch needs --worktree ." })) + : Effect.succeed(undefined), + onSome: (baseRef) => + Effect.succeed({ + type: "worktree" as const, + baseRef, + ...Option.match(input.branch, { onNone: () => ({}), onSome: (branch) => ({ branch }) }), + }), + }); + +const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Print JSON instead of text."), + Flag.withDefault(false), +); +const waitFlag = Flag.Boolean("wait").pipe( + Flag.withDescription( + "Wait for the agent to finish and print its reply. Exits 1 if the run fails and 2 if it waits for an answer.", + ), + Flag.withDefault(false), +); +const messageArgument = Argument.String("message").pipe( + Argument.withDescription("Message for the agent, or - to read it from stdin."), +); +const threadArgument = Argument.String("thread").pipe(Argument.withDescription("Thread id.")); + +const decodeThreadIdValue = Schema.decodeUnknownEffect(ThreadId); +const decodeTitle = Schema.decodeUnknownEffect(TrimmedNonEmptyString); +const decodeThreadId = (value: string) => + decodeThreadIdValue(value.trim()).pipe( + Effect.mapError(() => new ThreadUsageError({ detail: `'${value}' is not a thread id.` })), + ); + +const threadListCommand = Command.make("list", { + ...projectLocationFlags, + project: Flag.String("project").pipe( + Flag.withDescription("Project id or a path inside the project. Defaults to this directory."), + Flag.withDefault("."), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("List a project's threads, newest first."), + Command.withHandler((flags) => + withThreadTools( + flags, + Effect.fn("cli.thread.list")(function* (tools) { + const project = yield* resolveProject(tools, flags.project); + const threads: Array = []; + let cursor: number | null = 0; + while (cursor !== null) { + const page: OrchestratorMcpThreadListResult = yield* tools.call( + "t3_thread_list", + { projectId: project.id, includeSubagents: false, limit: 100, cursor }, + OrchestratorMcpThreadListResult, + ); + threads.push(...page.threads); + cursor = page.nextCursor; + } + if (flags.json) { + return yield* printJson(threads); + } + for (const thread of threads) { + yield* Console.log(`${thread.threadId} ${thread.status.padEnd(11)} ${thread.title}`); + } + }), + ).pipe(reportThreadErrors), + ), +); + +const threadStartCommand = Command.make("start", { + ...projectLocationFlags, + project: Argument.String("project").pipe( + Argument.withDescription("Project id or a path inside the project, such as ."), + ), + message: messageArgument, + title: Flag.String("title").pipe( + Flag.withDescription("Thread title. Defaults to the message's first line."), + Flag.optional, + ), + provider: Flag.String("provider").pipe( + Flag.withDescription("Provider instance id. Pass with --model."), + Flag.optional, + ), + model: Flag.String("model").pipe( + Flag.withDescription("Model slug. Defaults to the project's default model."), + Flag.optional, + ), + worktree: Flag.String("worktree").pipe( + Flag.withDescription( + "Run in a new git worktree based on this branch, tag, or commit, instead of the project folder.", + ), + Flag.optional, + ), + branch: Flag.String("branch").pipe( + Flag.withDescription("Branch name for the new worktree."), + Flag.optional, + ), + wait: waitFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Start a thread in a project and print its id."), + Command.withHandler((flags) => + Effect.gen(function* () { + const message = yield* readMessageArgument(flags.message); + const workspaceStrategy = yield* launchWorkspaceStrategy(flags); + if (Option.isSome(flags.provider) !== Option.isSome(flags.model)) { + return yield* new ThreadUsageError({ detail: "--provider and --model go together." }); + } + const title = yield* decodeTitle( + Option.getOrElse(flags.title, () => titleFromMessage(message)), + ).pipe(Effect.mapError(() => new ThreadUsageError({ detail: "The title is empty." }))); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.start")(function* (tools) { + const project = yield* resolveProject(tools, flags.project); + const launched = yield* tools.call( + "t3_thread_launch", + { + projectId: project.id, + title, + message, + ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }), + ...(Option.isSome(flags.provider) && Option.isSome(flags.model) + ? { + modelSelection: { + instanceId: flags.provider.value, + model: flags.model.value, + }, + } + : {}), + }, + ThreadLaunchResult, + ); + if (flags.wait && launched.runId !== null) { + if (!flags.json) yield* Console.error(`Thread ${launched.threadId}`); + return yield* finishRun(tools, { + threadId: launched.threadId, + runId: launched.runId, + json: flags.json, + }); + } + yield* flags.json ? printJson(launched) : Console.log(launched.threadId); + }), + ); + }).pipe(reportThreadErrors), + ), +); + +const threadSendCommand = Command.make("send", { + ...projectLocationFlags, + thread: threadArgument, + message: messageArgument, + wait: waitFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Send a message to a thread. A busy thread gets it as its next turn; nothing is interrupted.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const threadId = yield* decodeThreadId(flags.thread); + const message = yield* readMessageArgument(flags.message); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.send")(function* (tools) { + const sent = yield* tools.call( + "t3_thread_send", + { threadId, message, mode: "queue" }, + OrchestratorMcpThreadSendResult, + ); + if (flags.wait) { + return yield* finishRun(tools, { threadId, runId: sent.runId, json: flags.json }); + } + yield* flags.json + ? printJson(sent) + : Console.log(sent.delivery === "queued" ? "Queued." : "Sent."); + }), + ); + }).pipe(reportThreadErrors), + ), +); + +const threadShowCommand = Command.make("show", { + ...projectLocationFlags, + thread: threadArgument, + messages: Flag.Int("messages").pipe( + Flag.withDescription("How many of the latest messages to print."), + Flag.withDefault(10), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Show a thread's state and its latest messages."), + Command.withHandler((flags) => + Effect.gen(function* () { + const threadId = yield* decodeThreadId(flags.thread); + yield* withThreadTools( + flags, + Effect.fn("cli.thread.show")(function* (tools) { + const { thread, items } = yield* readTimeline(tools, threadId, "messages"); + const conversation = items.filter( + (item) => item.type === "user_message" || item.type === "assistant_message", + ); + const latest = conversation.slice(Math.max(0, conversation.length - flags.messages)); + const messages = yield* Effect.forEach(latest, (item) => + readFullText(tools, threadId, item).pipe( + Effect.map((text) => ({ + role: item.type === "user_message" ? "user" : "assistant", + runId: item.runId, + text, + })), + ), + ); + if (flags.json) { + return yield* printJson({ thread, messages }); + } + yield* Console.log(thread.title); + yield* Console.log( + [thread.threadId, thread.status, `${thread.providerInstanceId}/${thread.model}`].join( + " ", + ), + ); + if (thread.worktreePath !== null) { + yield* Console.log(`${thread.branch ?? "(no branch)"} ${thread.worktreePath}`); + } + if (thread.pendingRequestCount > 0) { + yield* Console.log(`Waiting for ${thread.pendingRequestCount} answer(s).`); + } + for (const entry of messages) { + yield* Console.log(`\n[${entry.role}]\n${entry.text}`); + } + }), + ); + }).pipe(reportThreadErrors), + ), +); + +export const threadCommand = Command.make("thread").pipe( + Command.withDescription("Start, message, and read threads on the running server."), + Command.withSubcommands([ + threadListCommand, + threadStartCommand, + threadSendCommand, + threadShowCommand, + ]), +); diff --git a/apps/server/src/cli/trace.test.ts b/apps/server/src/cli/trace.test.ts index 7e6abdbc323e..6c00b240f0a7 100644 --- a/apps/server/src/cli/trace.test.ts +++ b/apps/server/src/cli/trace.test.ts @@ -31,45 +31,6 @@ function browserSpan( }); } -it("reports count, rate, percentiles, and exits per span name", () => { - const refreshes = Array.from({ length: 10 }, (_, index) => - span( - "refresh", - index + 1, - index * MINUTE_MS, - index === 0 ? "Interrupted" : index === 1 ? "Failure" : "Success", - ), - ); - const summarizer = makeTraceSpanSummary(); - [...refreshes, span("probe", 2_500, 10 * MINUTE_MS)].forEach(summarizer.addLine); - const summary = summarizer.finish(); - - assert.strictEqual(summary.spanCount, 11); - assert.strictEqual(summary.minutes, 10); - assert.deepStrictEqual(summary.spans, [ - { - name: "refresh", - count: 10, - perMinute: 1, - p50Ms: 5, - p90Ms: 9, - maxMs: 10, - interrupted: 1, - failures: 1, - }, - { - name: "probe", - count: 1, - perMinute: 0.1, - p50Ms: 2_500, - p90Ms: 2_500, - maxMs: 2_500, - interrupted: 0, - failures: 0, - }, - ]); -}); - it("drops spans that ended before the window and counts unreadable lines", () => { const summarizer = makeTraceSpanSummary(MINUTE_MS); [ diff --git a/apps/server/src/cli/triage.test.ts b/apps/server/src/cli/triage.test.ts index 85dadef40227..7443c2425ac6 100644 --- a/apps/server/src/cli/triage.test.ts +++ b/apps/server/src/cli/triage.test.ts @@ -31,29 +31,3 @@ it.effect("reports the version the running server recorded, not the CLI's", () = ); }).pipe(Effect.provide(NodeServices.layer)), ); - -it.effect("says the server version is unknown when the state file has none", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const statePath = path.join( - yield* fs.makeTempDirectoryScoped({ prefix: "t3-triage-" }), - "server-runtime.json", - ); - yield* fs.writeFileString( - statePath, - JSON.stringify({ - version: 1, - pid: process.pid, - port: 3773, - origin: "http://127.0.0.1:3773", - startedAt: "2026-09-24T00:08:56.777Z", - }), - ); - - assert.equal( - yield* describeServerProcess(statePath), - `running (pid ${String(process.pid)}, http://127.0.0.1:3773, version not recorded)`, - ); - }).pipe(Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index f5470086d0d4..02e6c5e18859 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -4,12 +4,7 @@ import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; -import { - buildTriageContext, - buildTriageLaunchPrompt, - buildTriageSeedPrompt, - TRIAGE_PLAYBOOK, -} from "./triagePrompt.ts"; +import { buildTriageContext, buildTriageLaunchPrompt, TRIAGE_PLAYBOOK } from "./triagePrompt.ts"; it("stays byte-identical to .github/triage/PLAYBOOK.md", () => { // Old releases fetch the repo copy from `main` and follow it when it differs @@ -23,12 +18,6 @@ it("stays byte-identical to .github/triage/PLAYBOOK.md", () => { assert.equal(TRIAGE_PLAYBOOK, NodeFS.readFileSync(canonicalPath, "utf8")); }); -it("seed prompt names the context file and embeds the playbook", () => { - const prompt = buildTriageSeedPrompt("/tmp/triage-run/context.md"); - assert.include(prompt, "/tmp/triage-run/context.md"); - assert.include(prompt, TRIAGE_PLAYBOOK); -}); - it("launch prompt stays a single argv-safe line naming the prompt file", () => { // The launch argument goes through cmd.exe on Windows (.cmd shims), which // cannot carry newlines; the playbook itself must stay on disk. @@ -38,39 +27,6 @@ it("launch prompt stays a single argv-safe line naming the prompt file", () => { assert.isBelow(launch.length, 1_000); }); -it("context file carries every path the playbook depends on", () => { - const context = buildTriageContext({ - generatedAt: "2026-08-13T00:00:00.000Z", - cliVersion: "0.0.33", - os: "linux x64 (7.0.0)", - nodeVersion: "v24.0.0", - launchedAs: "npx t3 triage", - server: "running (pid 42, http://127.0.0.1:4501)", - paths: { - stateDir: "/home/u/.t3/userdata", - dbPath: "/home/u/.t3/userdata/state.sqlite", - settingsPath: "/home/u/.t3/userdata/settings.json", - logsDir: "/home/u/.t3/userdata/logs", - serviceLogPath: "/home/u/.t3/userdata/logs/boot-service.log", - desktopBackendLogGlob: "/home/u/.t3/userdata/logs/server-child*.log*", - serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", - providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", - terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", - providerStatusCacheDir: "/home/u/.t3/caches", - secretsDir: "/home/u/.t3/userdata/secrets", - sourceCacheDir: "/home/u/.t3/source", - }, - }); - assert.include(context, "/home/u/.t3/userdata/state.sqlite"); - assert.include(context, "/home/u/.t3/userdata/logs/server.trace.ndjson"); - assert.include(context, "/home/u/.t3/userdata/logs/boot-service.log"); - assert.include(context, "/home/u/.t3/userdata/logs/server-child*.log*"); - assert.include(context, "/home/u/.t3/userdata/logs/provider/events.log"); - assert.include(context, "/home/u/.t3/userdata/secrets"); - assert.include(context, "/home/u/.t3/source"); - assert.include(context, "npx t3 triage"); -}); - it("context file labels the CLI version as the CLI's, not the user's build", () => { // `npx t3 triage` can resolve a different `t3` than the app the bug // happened on (#13337), so the agent must not read it as the user's version. diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 784472be2465..12127541738f 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -14,10 +14,14 @@ import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as BootService from "../cloud/bootService.ts"; -import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts"; +import { + launcherOwnsVersionsDir, + pinnedRuntimeVersionsDir, + windowsShimTarget, +} from "../cloud/pinnedRuntime.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import * as CliService from "./service.ts"; -import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts"; +import { findWindowsShim, resolveLauncherPath } from "./update.ts"; export class CliUninstallError extends Schema.TaggedError()( "CliUninstallError", @@ -51,7 +55,7 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi const shimPath = yield* findWindowsShim(input.launchedAs); if (shimPath === undefined) return undefined; const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); - const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined; + const target = Option.isSome(contents) ? windowsShimTarget(contents.value) : undefined; return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) ? shimPath : undefined; diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 48c7c3a4f44c..e6a6eb5ee1db 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -1,54 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, expect, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as NetService from "@t3tools/shared/Net"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import { Command } from "effect/cli"; import { HttpClient, HttpClientResponse } from "effect/http"; import { afterEach, vi } from "vite-plus/test"; -import packageJson from "../../package.json" with { type: "json" }; -import * as BootService from "../cloud/bootService.ts"; -import { pinnedRuntimePaths } from "../cloud/pinnedRuntime.ts"; -import { - repointLauncher, - resolveLauncherPath, - resolveNewestVersion, - updateCommand, -} from "./update.ts"; +import { repointLauncher, resolveLauncherPath, resolveNewestVersion } from "./update.ts"; afterEach(() => vi.restoreAllMocks()); -it.effect.each([ - { environment: { GH_TOKEN: " gh-token ", GITHUB_TOKEN: "github-token" }, token: "gh-token" }, - { environment: { GH_TOKEN: " ", GITHUB_TOKEN: " github-token " }, token: "github-token" }, - { environment: { GITHUB_TOKEN: "github-token" }, token: "github-token" }, - { environment: { GH_TOKEN: " ", GITHUB_TOKEN: " " }, token: undefined }, - { environment: {}, token: undefined }, -])("selects release authentication from $environment", ({ environment, token }) => - Effect.gen(function* () { - const authorizations: Array = []; - const httpClient = HttpClient.make((request) => { - authorizations.push(request.headers.authorization); - return Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json([{ tag_name: "v1.2.3" }])), - ); - }); - const version = yield* resolveNewestVersion("stable").pipe( - Effect.provideService(HostProcess.Environment, environment), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); - assert.equal(version, "1.2.3"); - assert.deepStrictEqual(authorizations, [token === undefined ? undefined : `Bearer ${token}`]); - }), -); - it.effect("authenticates each release page until the requested channel is found", () => Effect.gen(function* () { const pages: string[] = []; @@ -165,78 +128,3 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); - -it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 update service", (it) => { - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "reports the service switch as pending after installing the target runtime", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-service-" }); - const targetVersion = "999.0.0"; - const runtime = pinnedRuntimePaths(path, baseDir, targetVersion, "linux"); - yield* fs.makeDirectory(runtime.versionDir, { recursive: true }); - yield* fs.writeFileString( - runtime.entryPath, - `#!/bin/sh\nprintf 't3 v${targetVersion}\\n'\n`, - ); - yield* fs.chmod(runtime.entryPath, 0o755); - yield* fs.writeFileString(runtime.sentinelPath, `${targetVersion}\n`); - const logPath = path.join(baseDir, "userdata/logs/boot-service.log"); - const unitPath = path.join(baseDir, "t3code.service"); - const installs: Array[0]> = []; - vi.spyOn(BootService, "layer").mockReturnValue( - Layer.succeed(BootService.BootService, { - status: Effect.succeed({ - supported: true, - installed: true, - current: true, - installedVersion: packageJson.version, - installedBaseDir: baseDir, - logPath, - unitPath, - }), - install: (options) => - Effect.sync(() => { - installs.push(options); - return { - program: [runtime.entryPath], - baseDir, - logPath, - unitPath, - handedOff: true, - }; - }), - restart: Effect.die("unexpected restart"), - uninstall: Effect.die("unexpected uninstall"), - withStopped: () => Effect.die("unexpected maintenance"), - }), - ); - const lines: string[] = []; - yield* Command.runWith(updateCommand, { version: packageJson.version })([ - targetVersion, - "--yes", - "--base-dir", - baseDir, - ]).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.IsExecutable, false), - Effect.provideService(HostProcess.Environment, {}), - Effect.provideService(Console.Console, { - ...globalThis.console, - log: (...args) => { - lines.push(args.map(String).join(" ")); - }, - }), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), - ); - - expect(installs).toEqual([{ allowDowngrade: false, start: true }]); - const output = lines.join("\n"); - expect(output).not.toContain("Background service restarted"); - expect(output).toContain(`Background service switching to ${targetVersion}`); - expect(output).toContain(logPath); - }), - ); -}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 9a35a47424e4..f5e5eafa8d26 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -22,9 +22,12 @@ import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { ensurePinnedRuntimeInstalled, + launcherOwnsVersionsDir, pinnedRuntimeCommand, PinnedRuntimeInstallError, pinnedRuntimePaths, + prunePinnedRuntimes, + windowsShimTarget, } from "../cloud/pinnedRuntime.ts"; import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -87,15 +90,6 @@ export const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(funct return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); }); -export function launcherOwnsVersionsDir( - path: Path.Path, - versionsDir: string, - candidate: string, -): boolean { - const relative = path.relative(versionsDir, path.resolve(candidate)); - return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); -} - /** * Only a launcher that already points into this home's `runtime/versions` tree is touched; a * plain copy of the executable, or a launcher for some other install, is left alone. @@ -116,7 +110,7 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function const shimPath = yield* findWindowsShim(input.launchedAs); if (shimPath === undefined) return Option.none(); const current = yield* fs.readFileString(shimPath).pipe(Effect.option); - const quoted = Option.isSome(current) ? /^"([^"]+)"/m.exec(current.value)?.[1] : undefined; + const quoted = Option.isSome(current) ? windowsShimTarget(current.value) : undefined; if (quoted === undefined || !ownsTarget(quoted)) return Option.none(); yield* fs .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`) @@ -188,7 +182,7 @@ export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(functio const shimPath = path.join(directory, "t3.cmd"); const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); if (Option.isNone(contents)) continue; - const target = /^"([^"]+)"/m.exec(contents.value)?.[1]; + const target = windowsShimTarget(contents.value); if ( target !== undefined && path.resolve(target).toLowerCase() === path.resolve(executablePath).toLowerCase() @@ -521,6 +515,17 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ); } + // A handed-off service install rewrites the unit later, while the old launcher may be stopped. + if (servicePlan?.handedOff !== true) { + // A stopped service's unit still names the launcher runtime it will start. + const unit = yield* fs.readFileString(status.unitPath).pipe(Effect.option); + yield* prunePinnedRuntimes({ + baseDir: input.baseDir, + keep: [currentVersion, targetVersion], + references: Option.toArray(Option.flatMapNullishOr(unit, BootService.bootServiceProgramOf)), + }); + } + progress.success(`Installed T3 Code ${targetVersion}`); if (Option.isSome(repointed)) { yield* Console.log(" Run t3 to get started.\n"); diff --git a/apps/server/src/cli/updateProgress.test.ts b/apps/server/src/cli/updateProgress.test.ts deleted file mode 100644 index 76eff4f3e8ff..000000000000 --- a/apps/server/src/cli/updateProgress.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, expect, it, vi } from "vite-plus/test"; - -import { createUpdateProgress } from "./updateProgress.ts"; - -afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -function terminal(isTTY = true, columns = 80) { - vi.stubEnv("TERM", "xterm"); - vi.stubEnv("NO_COLOR", "1"); - let text = ""; - const progress = createUpdateProgress({ - isTTY, - columns, - write(chunk: string | Uint8Array) { - text += chunk; - return true; - }, - }); - return { ...progress, text: () => text }; -} - -it("keeps redirected output readable without per-chunk updates or escapes", () => { - const output = terminal(false); - for (let received = 0; received <= 100; received++) { - output.report({ stage: "download", received, total: 100 }); - } - output.report({ stage: "verify" }); - output.report({ stage: "extract" }); - output.report({ stage: "validate" }); - output.finish(); - expect(output.text()).toBe( - " Downloading...\n Verifying the download...\n Extracting T3 Code...\n Checking the new executable...\n", - ); -}); - -it("throttles redraws, shows the final byte count, and closes the line once", () => { - const now = vi.spyOn(performance, "now").mockReturnValue(0); - const output = terminal(); - output.report({ stage: "download", received: 0, total: 1024 ** 2 }); - const initial = output.text(); - for (let received = 1; received < 100; received++) { - output.report({ stage: "download", received, total: 1024 ** 2 }); - } - expect(output.text()).toBe(initial); - now.mockReturnValue(100); - output.report({ stage: "download", received: 524288, total: 1024 ** 2 }); - output.report({ stage: "download", received: 1024 ** 2, total: 1024 ** 2 }); - output.finish(); - output.finish(); - expect(output.text().match(/100%/g)).toHaveLength(1); - expect(output.text()).toContain("50% 0.5 / 1.0 MB"); - expect(output.text()).toMatch(/100% 1.0 \/ 1.0 MB\n$/); -}); - -it("shows bytes for an unknown size and leaves a clean line on interruption", () => { - const output = terminal(); - output.report({ stage: "download", received: 524288, total: undefined }); - output.finish(); - expect(output.text()).toMatch(/0.5 MB\n$/); - expect(output.text()).not.toContain("%"); -}); - -it.each([1, 2, 7, 8, 9, 30])("fits a %i-column terminal without wrapping", (columns) => { - const output = terminal(true, columns); - output.report({ stage: "download", received: 50, total: 100 }); - for (const line of output.text().split("\r\x1b[2K")) { - expect(line.length).toBeLessThan(columns); - } -}); - -it("draws the final size of a fast chunked download even inside the throttle window", () => { - vi.spyOn(performance, "now").mockReturnValue(0); - const output = terminal(); - output.report({ stage: "download", received: 0, total: undefined }); - output.report({ stage: "download", received: 524288, total: undefined }); - output.report({ stage: "download", received: 524288, total: 524288 }); - output.report({ stage: "verify" }); - expect(output.text()).toContain("100% 0.5 / 0.5 MB\n\r\x1b[2K Verifying"); -}); - -it("uses color only in capable terminals that have not requested NO_COLOR", () => { - const output = terminal(); - vi.stubEnv("NO_COLOR", undefined); - const chunks: string[] = []; - const progress = createUpdateProgress({ - isTTY: true, - columns: 80, - write(chunk) { - chunks.push(String(chunk)); - return true; - }, - }); - progress.report({ stage: "download", received: 50, total: 100 }); - expect(chunks.join("")).toContain("\x1b[94m"); - output.report({ stage: "download", received: 50, total: 100 }); - expect(output.text()).not.toContain("\x1b[94m"); -}); diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts deleted file mode 100644 index 5828d6518a96..000000000000 --- a/apps/server/src/cliAuthFormat.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; - -import { - formatIssuedPairingCredential, - formatIssuedSession, - formatPairingCredentialList, - formatSessionList, -} from "./cliAuthFormat.ts"; - -it("formats issued pairing credentials with the secret and optional pair URL", () => { - const output = formatIssuedPairingCredential( - { - id: "pairing-1", - credential: "secret-pairing-token", - scopes: ["orchestration:read"], - subject: "one-time-token", - createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), - expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), - }, - { baseUrl: "https://example.com", json: false }, - ); - - expect(output).toContain("secret-pairing-token"); - expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); - expect(output).toContain("Expires at: 2026-04-08T10:00:00.000Z"); - expect(output).not.toContain("DateTime.Utc"); -}); - -it("formats pairing listings without exposing the secret token", () => { - const output = formatPairingCredentialList( - [ - { - id: "pairing-1", - subject: "one-time-token", - label: "Phone", - scopes: ["orchestration:read"], - createdAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), - expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), - }, - ], - { json: false }, - ); - - expect(output).toContain("pairing-1"); - expect(output).not.toContain("secret-pairing-token"); -}); - -it("formats issued sessions with the bearer token but omits tokens from listings", () => { - const issuedOutput = formatIssuedSession( - { - sessionId: "session-1" as never, - token: "secret-session-token", - method: "bearer-access-token", - scopes: ["orchestration:read", "access:write"], - subject: "cli-issued-session", - client: { - label: "deploy-bot", - deviceType: "bot", - }, - expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), - }, - { json: false }, - ); - - const listedOutput = formatSessionList( - [ - { - sessionId: "session-1" as never, - method: "bearer-access-token", - scopes: ["orchestration:read", "access:write"], - subject: "cli-issued-session", - client: { - label: "deploy-bot", - deviceType: "bot", - }, - connected: false, - current: false, - issuedAt: DateTime.makeUnsafe("2026-04-08T09:00:00.000Z"), - expiresAt: DateTime.makeUnsafe("2026-04-08T10:00:00.000Z"), - lastConnectedAt: null, - }, - ], - { json: false }, - ); - - expect(issuedOutput).toContain("secret-session-token"); - expect(listedOutput).not.toContain("secret-session-token"); -}); diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 76e8efa3d015..c64ca01e81b1 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -1,6 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -9,12 +8,9 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; -import * as Terminal from "effect/Terminal"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -57,60 +53,6 @@ const provideTestEnv = Effect.provide( ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })), ); -const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError); - -const makeTestTerminal = (queue: Queue.Queue) => - Terminal.make({ - columns: Effect.succeed(80), - rows: Effect.succeed(24), - readInput: Effect.succeed(Queue.asDequeue(queue)), - readLine: Effect.never, - display: () => Effect.void, - }); - -const userInput = (name: string): Terminal.UserInput => ({ - input: Option.some(name), - key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() }, -}); - -it.effect("opens the browser on Enter and switches the active flow on H", () => - Effect.gen(function* () { - const queue = yield* Queue.make(); - yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]); - const opened: Array = []; - - const result = yield* CliTokenManager.waitForLoopbackAuthorization({ - authorizationUrl: "https://clerk.example.test/authorize", - callback: Effect.never, - terminal: makeTestTerminal(queue), - launchBrowser: (url) => - Effect.sync(() => { - opened.push(url); - }), - }); - - assert.deepEqual(opened, ["https://clerk.example.test/authorize"]); - assert.deepEqual(result, { _tag: "HeadlessRequested" }); - }), -); - -it.effect("finishes normally when the browser callback wins", () => - Effect.gen(function* () { - const queue = yield* Queue.make(); - const callback = yield* Deferred.make(); - yield* Deferred.succeed(callback, "clerk-code-123"); - - const result = yield* CliTokenManager.waitForLoopbackAuthorization({ - authorizationUrl: "https://clerk.example.test/authorize", - callback: Deferred.await(callback), - terminal: makeTestTerminal(queue), - launchBrowser: () => Effect.die("browser launch should not run"), - }); - - assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" }); - }), -); - interface DeviceFlowServer { readonly requests: Array; readonly tokenReplies: Array<{ readonly status: number; readonly body: string }>; @@ -164,23 +106,6 @@ const layerDeviceFlow = (server: DeviceFlowServer) => const tokenRequests = (requests: ReadonlyArray) => requests.filter((request) => request.url.endsWith("/oauth/token")); -const layerStalledBody = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start: (controller) => controller.enqueue(new TextEncoder().encode("{")), - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ), - ), - ), -); - const expiredStoredToken = new TextEncoder().encode( JSON.stringify({ accessToken: "expired", refreshToken: "refresh-token-1", expiresAtEpochMs: 0 }), ); @@ -288,9 +213,7 @@ it.effect( it.effect.each([ { status: 400, code: "invalid_grant", requiresLogin: true, diagnostic: true }, { status: 403, code: "private-provider-secret", requiresLogin: true, diagnostic: false }, - { status: 408, code: "invalid_grant", requiresLogin: false, diagnostic: false }, { status: 429, code: "invalid_grant", requiresLogin: false, diagnostic: false }, - { status: 500, code: "invalid_grant", requiresLogin: false, diagnostic: false }, ])( "refresh HTTP $status exposes only a safe login diagnostic", ({ status, code, requiresLogin, diagnostic }) => @@ -325,96 +248,6 @@ it.effect.each([ ), ); -it.effect.each(["malformed", "unreadable", "stalled"] as const)( - "a $0 rejection body preserves HTTP status and its underlying failure", - (bodyKind) => - Effect.gen(function* () { - const readerFailure = new Error("private-reader-failure"); - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - bodyKind === "malformed" - ? "{" - : new ReadableStream({ - start: (controller) => { - if (bodyKind === "unreadable") controller.error(readerFailure); - else controller.enqueue(new TextEncoder().encode("{")); - }, - }), - { status: 400 }, - ), - ), - ).pipe(Effect.delay(bodyKind === "stalled" ? Duration.seconds(4) : Duration.zero)), - ); - yield* Effect.gen(function* () { - const manager = yield* CliTokenManager.CloudCliTokenManager; - const refresh = yield* manager.getExisting.pipe( - Effect.flip, - Effect.timed, - Effect.forkChild, - ); - const reader = yield* manager.hasCredential.pipe(Effect.forkChild); - if (bodyKind === "stalled") yield* TestClock.adjust(Duration.seconds(14)); - const [duration, error] = yield* Fiber.join(refresh); - if (bodyKind === "stalled") assert.equal(Duration.toMillis(duration), 10_000); - assert.instanceOf(error, CliTokenManager.CloudCliCredentialRefreshError); - assert.isTrue(error.requiresLogin); - assert.include(error.message, "t3 connect login"); - assert.notInclude(error.message, "private"); - assert.notInclude(error.message, "OAuth error:"); - assert.isTrue(HttpClientError.isHttpClientError(error.cause)); - if (!HttpClientError.isHttpClientError(error.cause)) return; - assert.equal(error.cause.reason._tag, "StatusCodeError"); - if (error.cause.reason._tag !== "StatusCodeError") return; - assert.equal(error.cause.reason.response.status, 400); - const underlying = error.cause.reason.cause; - if (bodyKind === "unreadable") { - assert.isTrue(HttpClientError.isHttpClientError(underlying)); - if (!HttpClientError.isHttpClientError(underlying)) return; - assert.equal(underlying.reason._tag, "DecodeError"); - if (underlying.reason._tag === "DecodeError") - assert.strictEqual(underlying.reason.cause, readerFailure); - } else if (bodyKind === "stalled") { - assert.isTrue(Cause.isTimeoutError(underlying)); - } - assert.isTrue(yield* Fiber.join(reader)); - }).pipe(provideRefreshManager(client)); - }), -); - -it.effect("a stalled token refresh fails and releases the credential lock", () => - Effect.gen(function* () { - const manager = yield* CliTokenManager.CloudCliTokenManager; - const refresh = yield* manager.getExisting.pipe(Effect.flip, Effect.forkChild); - const hasCredential = yield* manager.hasCredential.pipe(Effect.forkChild); - - yield* TestClock.adjust(Duration.seconds(10)); - - const error = yield* Fiber.join(refresh); - assert.instanceOf(error, CliTokenManager.CloudCliCredentialRefreshError); - assert.isFalse(error.requiresLogin); - assert.isTrue(yield* Fiber.join(hasCredential)); - }).pipe( - Effect.provide( - CliTokenManager.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerStalledBody, - Layer.mock(ServerSecretStore.ServerSecretStore)({ - get: () => Effect.succeedSome(expiredStoredToken), - }), - Layer.mock(ExternalLauncher.ExternalLauncher)({}), - NodeServices.layer, - ), - ), - ), - ), - provideTestEnv, - ), -); - it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => { it.effect("requests a device code, shows it, and polls until Clerk grants the token", () => Effect.gen(function* () { @@ -462,55 +295,6 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => }), ); - it.effect("waits the advertised interval between polls and backs off on slow_down", () => - Effect.gen(function* () { - const server: DeviceFlowServer = { - requests: [], - tokenReplies: [oauthError("slow_down"), oauthError("authorization_pending")], - }; - - const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( - Effect.provide(layerDeviceFlow(server)), - provideTestEnv, - Effect.forkChild, - ); - - yield* TestClock.adjust(Duration.seconds(4)); - assert.lengthOf(tokenRequests(server.requests), 0); - yield* TestClock.adjust(Duration.seconds(1)); - assert.lengthOf(tokenRequests(server.requests), 1); - yield* TestClock.adjust(Duration.seconds(9)); - assert.lengthOf(tokenRequests(server.requests), 1); - yield* TestClock.adjust(Duration.seconds(1)); - assert.lengthOf(tokenRequests(server.requests), 2); - yield* Fiber.interrupt(fiber); - }), - ); - - it.effect("backs off after a transient upstream failure and keeps polling", () => - Effect.gen(function* () { - const server: DeviceFlowServer = { - requests: [], - tokenReplies: [{ status: 503, body: "upstream unavailable" }, tokenGranted], - }; - - const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( - Effect.provide(layerDeviceFlow(server)), - provideTestEnv, - Effect.forkChild, - ); - - yield* TestClock.adjust(Duration.seconds(5)); - assert.lengthOf(tokenRequests(server.requests), 1); - yield* TestClock.adjust(Duration.seconds(9)); - assert.lengthOf(tokenRequests(server.requests), 1); - yield* TestClock.adjust(Duration.seconds(1)); - const { token } = yield* Fiber.join(fiber); - assert.lengthOf(tokenRequests(server.requests), 2); - assert.equal(token.accessToken, "access-token-1"); - }), - ); - it.effect("fails with a denied error when the user rejects the request", () => Effect.gen(function* () { const server: DeviceFlowServer = { @@ -531,58 +315,4 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => assert.lengthOf(tokenRequests(server.requests), 1); }), ); - - it.effect("times out once the device code lifetime elapses", () => - Effect.gen(function* () { - const server: DeviceFlowServer = { - requests: [], - tokenReplies: [oauthError("authorization_pending")], - }; - - const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( - Effect.provide(layerDeviceFlow(server)), - provideTestEnv, - Effect.flip, - Effect.forkChild, - ); - yield* TestClock.adjust(Duration.seconds(600)); - const result = yield* Fiber.join(fiber); - - assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError); - }), - ); - - it.effect("surfaces other OAuth errors as authorization failures", () => - Effect.gen(function* () { - const server: DeviceFlowServer = { - requests: [], - tokenReplies: [oauthError("invalid_client")], - }; - - const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( - Effect.provide(layerDeviceFlow(server)), - provideTestEnv, - Effect.flip, - Effect.forkChild, - ); - yield* TestClock.adjust(Duration.seconds(5)); - const result = yield* Fiber.join(fiber); - - assert.isTrue(isAuthorizationError(result)); - }), - ); - - it.effect("fails instead of hanging when the device authorization response stalls", () => - Effect.gen(function* () { - const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( - Effect.provide(layerStalledBody), - provideTestEnv, - Effect.flip, - Effect.forkChild, - ); - yield* TestClock.adjust(Duration.seconds(10)); - - assert.isTrue(Cause.isTimeoutError(yield* Fiber.join(fiber))); - }), - ); }); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 5b041a9efba2..28d5125de565 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -79,48 +79,48 @@ const readLoopbackAuthorizationAction = Effect.fn( } }); -export const waitForLoopbackAuthorization = Effect.fn( - "cloud.cli_token.wait_for_loopback_authorization", -)(function* (input: { - readonly authorizationUrl: string; - readonly callback: Effect.Effect; - readonly terminal: Terminal.Terminal; - readonly launchBrowser: ( - url: string, - ) => Effect.Effect; -}) { - return yield* Effect.scoped( - Effect.gen(function* () { - const terminalInput = yield* input.terminal.readInput; - while (true) { - const result = yield* Effect.raceFirst( - input.callback.pipe( - Effect.map((code): LoopbackAuthorizationResult => ({ - _tag: "AuthorizationCode", - code, - })), - ), - readLoopbackAuthorizationAction(terminalInput), - ); - if (typeof result !== "string") { - return result; - } - if (result === "headless") { - return { _tag: "HeadlessRequested" } as const; - } - yield* input - .launchBrowser(input.authorizationUrl) - .pipe( - Effect.catch(() => - Console.warn( - `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`, - ), +const waitForLoopbackAuthorization = Effect.fn("cloud.cli_token.wait_for_loopback_authorization")( + function* (input: { + readonly authorizationUrl: string; + readonly callback: Effect.Effect; + readonly terminal: Terminal.Terminal; + readonly launchBrowser: ( + url: string, + ) => Effect.Effect; + }) { + return yield* Effect.scoped( + Effect.gen(function* () { + const terminalInput = yield* input.terminal.readInput; + while (true) { + const result = yield* Effect.raceFirst( + input.callback.pipe( + Effect.map((code): LoopbackAuthorizationResult => ({ + _tag: "AuthorizationCode", + code, + })), ), + readLoopbackAuthorizationAction(terminalInput), ); - } - }), - ); -}); + if (typeof result !== "string") { + return result; + } + if (result === "headless") { + return { _tag: "HeadlessRequested" } as const; + } + yield* input + .launchBrowser(input.authorizationUrl) + .pipe( + Effect.catch(() => + Console.warn( + `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`, + ), + ), + ); + } + }), + ); + }, +); const PersistedToken = Schema.Struct({ accessToken: Schema.String, diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 653a3ce07710..7264afd6a452 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -1,21 +1,17 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse, HttpServer, type HttpClientRequest } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; -import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfigModule from "../config.ts"; @@ -36,7 +32,6 @@ import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINK_REVOKED, CLOUD_LINKED_USER_ID, - decodeConfirmedOrigin, decodeRuntimeConfig, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, @@ -46,10 +41,7 @@ import * as CloudLink from "./CloudLink.ts"; import * as ConnectReachability from "./ConnectReachability.ts"; import { consumeCloudReplayGuards } from "./CloudLink.ts"; import { pendingServiceUpdateExists } from "./updateHandoff.ts"; -import { - managedTunnelStartupAction, - retryManagedTunnelRegistration, -} from "./managedTunnelStartup.ts"; +import { managedTunnelStartupAction } from "./managedTunnelStartup.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => @@ -93,30 +85,6 @@ function makeSecretStore( }; } -it("preserves messages surfaced by cloud 500 responses", () => { - const cause = new Error("cloud operation failed"); - - expect([ - new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, - new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, - new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, - new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, - new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, - new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, - new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, - new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, - ]).toEqual([ - "Could not verify the linked cloud account.", - "Could not read the linked cloud account.", - "Cloud linked user is not installed for this environment.", - "Failed to sign cloud link JWT.", - "Cloud mint public key is not installed for this environment.", - "Cloud relay issuer is not installed for this environment.", - "Failed to sign cloud health JWT.", - "Failed to sign cloud mint JWT.", - ]); -}); - describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { @@ -129,89 +97,6 @@ describe("consumeCloudReplayGuards", () => { expect(consumed).toBe(false); }), ); - - it.effect("preserves replay-store availability failures", () => - Effect.gen(function* () { - const failure = storeFailure("PermissionDenied"); - const error = yield* Effect.flip( - consumeCloudReplayGuards({ - secrets: makeSecretStore(() => Effect.fail(failure)), - names: ["cloud-jti", "cloud-nonce"], - value: new Uint8Array(), - }), - ); - - expect(error).toBe(failure); - }), - ); -}); - -describe("reconcileDesiredCloudLink", () => { - it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => - Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const error = yield* Effect.flip(link.reconcileDesiredLink("http://127.0.0.1:3774")); - - expect(error._tag).toBe("CloudLinkAuthorizationMissingError"); - }).pipe( - Effect.provide(CloudLink.layer), - Effect.provideService( - ConnectReachability.ConnectReachability, - ConnectReachability.ConnectReachability.of({ - check: Effect.die("unused"), - latest: Effect.succeed(Option.none()), - }), - ), - Effect.provideService(HttpServer.HttpServer, idleHttpServer), - Effect.provideService( - ServerConfigModule.ServerConfig, - ServerConfigModule.ServerConfig.of({} as ServerConfigModule.ServerConfig["Service"]), - ), - Effect.provideService( - ServerSecretStore.ServerSecretStore, - makeSecretStore(unusedSecretStoreOperation), - ), - Effect.provideService( - ServerEnvironment.ServerEnvironment, - ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: unusedSecretStoreOperation(), - getDescriptor: unusedSecretStoreOperation(), - }), - ), - Effect.provideService( - ManagedEndpointRuntime.CloudManagedEndpointRuntime, - ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: unusedSecretStoreOperation, - recoveryRequests: Stream.empty, - requestRecovery: () => Effect.void, - tunnelConnected: Stream.empty, - inspectConnector: Effect.succeed(null), - readinessChanged: Stream.empty, - withLinkStateLock: (effect) => effect, - } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), - ), - Effect.provideService( - EnvironmentAuth.EnvironmentAuth, - EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), - ), - Effect.provideService( - CliTokenManager.CloudCliTokenManager, - CliTokenManager.CloudCliTokenManager.of({ - get: unusedSecretStoreOperation(), - getExisting: Effect.succeedNone, - hasCredential: unusedSecretStoreOperation(), - store: () => unusedSecretStoreOperation(), - clear: unusedSecretStoreOperation(), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => unusedSecretStoreOperation()), - ), - Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), - Effect.provide(NodeServices.layer), - ), - ); }); describe("releaseManagedTunnelOnShutdown", () => { @@ -269,18 +154,6 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); - const writeDesktopUpdateRestartMarker = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const config = yield* ServerConfigModule.ServerConfig; - const runtimeDir = path.join(config.baseDir, "runtime"); - const markerPath = path.join(runtimeDir, DESKTOP_UPDATE_RESTART_MARKER_FILE); - yield* fs.makeDirectory(runtimeDir, { recursive: true }); - yield* fs.writeFileString(markerPath, ""); - const { mtime } = yield* fs.stat(markerPath); - return Option.getOrThrow(mtime).getTime(); - }); - const provideReleaseHarness = (harness: ReleaseHarness) => (effect: Effect.Effect) => @@ -414,21 +287,6 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); - it.effect("does nothing for links without a stored managed tunnel runtime config", () => { - const { store } = makeMemorySecretStore(); - const applyConfigCalls: Array = []; - const requests: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const released = yield* link.releaseManagedTunnelOnShutdown(); - - expect(released).toBe(false); - expect(applyConfigCalls).toEqual([]); - expect(requests).toEqual([]); - }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); - }); - it.effect("leaves the tunnel of a web/mobile-installed link untouched", () => { const { store, values } = makeMemorySecretStore([ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], @@ -448,26 +306,6 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); - it.effect("leaves the tunnel of a publish-only desired link untouched", () => { - const { store, values } = makeMemorySecretStore([ - [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], - [RELAY_URL_SECRET, "https://relay.example.test"], - [CLOUD_CLI_DESIRED_LINK_SECRET, "publish_only"], - ]); - const applyConfigCalls: Array = []; - const requests: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const released = yield* link.releaseManagedTunnelOnShutdown(); - - expect(released).toBe(false); - expect(applyConfigCalls).toEqual([]); - expect(requests).toEqual([]); - expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); - }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); - }); - it.effect("keeps the tunnel when shutdown hands off to a pending update", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; @@ -492,39 +330,6 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); - it.effect("keeps the tunnel once when the desktop app restarts it for an update", () => { - const { store, values } = makeMemorySecretStore(managedLinkSecrets); - const applyConfigCalls: Array = []; - const requests: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - yield* TestClock.setTime(yield* writeDesktopUpdateRestartMarker); - - expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(false); - expect(requests).toEqual([]); - expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); - - expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(true); - expect(requests).toHaveLength(1); - }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); - }); - - it.effect("releases the tunnel when the desktop update marker is stale", () => { - const { store } = makeMemorySecretStore(managedLinkSecrets); - const applyConfigCalls: Array = []; - const requests: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const writtenAt = yield* writeDesktopUpdateRestartMarker; - yield* TestClock.setTime(writtenAt + Duration.toMillis(Duration.minutes(2))); - - expect(yield* link.releaseManagedTunnelOnShutdown()).toBe(true); - expect(requests).toHaveLength(1); - }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); - }); - it.effect("still releases a pending update when the launcher is stopping", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; @@ -553,6 +358,7 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); + // regression: 808d6853555 keep the managed tunnel across remote update restarts it.effect("still releases when the recorded update already settled", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; @@ -623,29 +429,6 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); - it.effect("keeps the stored connector token when the relay release request fails", () => { - const { store, values } = makeMemorySecretStore(managedLinkSecrets); - const applyConfigCalls: Array = []; - const requests: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const result = yield* Effect.result(link.releaseManagedTunnelOnShutdown()); - - expect(result._tag).toBe("Failure"); - expect(requests).toHaveLength(1); - - expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); - }).pipe( - provideReleaseHarness({ - store, - applyConfigCalls, - requests, - respond: () => Response.json({ ok: false }, { status: 503 }), - }), - ); - }); - it.effect("registers an existing tunnel and starts the confirmed connector", () => { const { store } = makeMemorySecretStore([ [ @@ -843,94 +626,6 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] })); }); - it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "existing-token", - tunnelId: "existing-tunnel", - }; - const { store } = makeMemorySecretStore([ - [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)], - [ - CLOUD_ENDPOINT_CONFIRMED_ORIGIN, - JSON.stringify({ - config, - origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, - }), - ], - [RELAY_URL_SECRET, "https://relay.example.test"], - [CLOUD_LINKED_USER_ID, "user-123"], - [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], - ]); - const applyConfigCalls: Array = []; - const requests: Array = []; - let relayAvailable = false; - const localOrigin = "http://127.0.0.1:4884"; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const fallbackStarted = yield* Deferred.make(); - const firstFailure = yield* Deferred.make(); - expect(yield* link.startManagedTunnelIfOriginConfirmed(localOrigin)).toBe(false); - const registration = yield* Effect.forkChild( - retryManagedTunnelRegistration( - link - .registerManagedTunnelRecovery(localOrigin) - .pipe(Effect.tapError(() => Deferred.succeed(firstFailure, undefined))), - CloudLink.shouldRetryCloudLink, - link - .startManagedTunnelIfOriginConfirmed(localOrigin, { - requireConfirmedOrigin: false, - }) - .pipe( - Effect.orDie, - Effect.tap((started) => { - expect(started).toBe(true); - return Deferred.succeed(fallbackStarted, undefined); - }), - Effect.asVoid, - ), - ), - { startImmediately: true }, - ); - yield* Deferred.await(firstFailure); - yield* TestClock.adjust("15 minutes"); - yield* Effect.raceFirst( - Deferred.await(fallbackStarted), - Fiber.join(registration).pipe( - Effect.andThen(Effect.die("Registration ended before starting the fallback")), - ), - ); - expect(applyConfigCalls).toEqual([config]); - const attemptsBeforeRecovery = requests.length; - - relayAvailable = true; - yield* TestClock.adjust("1 minute"); - expect(yield* Fiber.join(registration)).toMatchObject({ status: "ready" }); - expect(requests.length).toBeGreaterThan(attemptsBeforeRecovery); - const marker = yield* store.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - expect(Option.isSome(marker)).toBe(true); - if (Option.isSome(marker)) { - expect( - Option.getOrThrow(decodeConfirmedOrigin(new TextDecoder().decode(marker.value))), - ).toEqual({ - config, - origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, - }); - } - }).pipe( - provideReleaseHarness({ - store, - applyConfigCalls, - requests, - respond: () => - relayAvailable - ? Response.json({ status: "ready" }) - : Response.json({ message: "relay unavailable" }, { status: 503 }), - }), - ); - }); - it.effect( "starts a connector with a marker for the current origin without contacting relay", () => { @@ -988,6 +683,7 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); + // regression: #9386 remove tunnels after hosts go offline it.effect( "starts the stored connector without a marker when confirmation is not required", () => { @@ -1143,98 +839,6 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); - it.effect("marks the link revoked when the relay rejects a running host's recovery", () => { - const { store, values } = makeMemorySecretStore([ - [ - CLOUD_ENDPOINT_RUNTIME_CONFIG, - '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}', - ], - [RELAY_URL_SECRET, "https://relay.example.test"], - [CLOUD_LINKED_USER_ID, "user-123"], - [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], - ]); - const applyConfigCalls: Array = []; - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773")); - expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" }); - expect((yield* link.linkState()).linkRevoked).toBe(true); - expect(applyConfigCalls).toEqual([]); - expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true); - }).pipe( - provideReleaseHarness({ - store, - applyConfigCalls, - requests: [], - respond: () => - Response.json( - { - _tag: "RelayAuthInvalidError", - code: "auth_invalid", - reason: "not_authorized", - traceId: "trace-recovery-revoked", - }, - { status: 401 }, - ), - }), - ); - }); - - it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => - Effect.gen(function* () { - const oldConfig = - '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; - const nextConfig = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "new-token", - tunnelId: "new-tunnel", - }; - const { store } = makeMemorySecretStore([ - [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig], - [RELAY_URL_SECRET, "https://relay.example.test"], - [CLOUD_LINKED_USER_ID, "user-123"], - [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], - ]); - const applyConfigCalls: Array = []; - const requests: Array = []; - const requestStarted = yield* Deferred.make(); - const response = yield* Deferred.make(); - const recovery = yield* CloudLink.CloudLink.use((link) => - link.recoverManagedTunnel("http://127.0.0.1:3773"), - ).pipe( - provideReleaseHarness({ - store, - applyConfigCalls, - requests, - onRequest: () => Deferred.succeed(requestStarted, undefined), - respondEffect: Deferred.await(response), - }), - Effect.forkChild({ startImmediately: true }), - ); - - yield* Deferred.await(requestStarted); - expect(requests).toHaveLength(1); - yield* TestClock.adjust("11 seconds"); - yield* Effect.yieldNow; - yield* Deferred.succeed( - response, - Response.json({ - endpoint: { - httpBaseUrl: "https://environment.example.test/", - wsBaseUrl: "wss://environment.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: nextConfig, - }), - ); - - expect(yield* Fiber.join(recovery)).toBe(true); - expect(requests).toHaveLength(1); - expect(applyConfigCalls).toEqual([nextConfig]); - }), - ); - it.effect("does not recover an environment without a managed tunnel credential", () => { const { store } = makeMemorySecretStore([ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], @@ -1279,7 +883,6 @@ describe("releaseManagedTunnelOnShutdown", () => { }); it.effect.each([ - { status: 401, body: {}, rejection: "unauthorized" }, { status: 401, body: { @@ -1357,45 +960,6 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); - it.effect("startup retries transient refresh failures but stops when login is required", () => { - const { store } = makeMemorySecretStore(managedLinkSecrets); - const requests: Array = []; - const applyConfigCalls: Array = []; - const permanent = new CliTokenManager.CloudCliCredentialRefreshError({ - cause: new Error("private-oauth-failure"), - status: 400, - oauthErrorCode: "invalid_grant", - }); - let attempts = 0; - const getExisting = Effect.suspend(() => { - attempts++; - if (attempts > 2) return Effect.die("retried a permanent credential rejection"); - return Effect.fail( - attempts === 2 - ? permanent - : new CliTokenManager.CloudCliCredentialRefreshError({ - cause: new Error("connection reset"), - }), - ); - }); - - return Effect.gen(function* () { - const link = yield* CloudLink.CloudLink; - const registration = yield* retryManagedTunnelRegistration( - link.reconcileDesiredLinkIfStillDesired("http://127.0.0.1:3773"), - CloudLink.shouldRetryCloudLink, - ).pipe(Effect.flip, Effect.forkChild); - yield* TestClock.adjust("10 seconds"); - const error = yield* Fiber.join(registration); - expect(attempts).toBe(2); - expect(error).toBe(permanent); - expect(error.message).toContain("t3 connect login"); - expect(error.message).not.toContain("private"); - expect(requests).toEqual([]); - expect(applyConfigCalls).toEqual([]); - }).pipe(provideReleaseHarness({ store, requests, applyConfigCalls, getExisting })); - }); - it.effect("keeps a tunnel configuration replaced during recovery", () => { const { store, values } = makeMemorySecretStore([ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], diff --git a/apps/server/src/cloud/CloudLink.test.ts b/apps/server/src/cloud/CloudLink.test.ts index db945509a56f..743ec8bc7a16 100644 --- a/apps/server/src/cloud/CloudLink.test.ts +++ b/apps/server/src/cloud/CloudLink.test.ts @@ -185,19 +185,6 @@ it.effect("keeps both settings unchanged when the relay refuses the hold change" ), ); -it.effect("changes nothing when the current activity setting can't be read", () => - withService({ failActivityRead: true, relayFails: true }, ({ preferences, stored, relayCalls }) => - Effect.gen(function* () { - const error = yield* preferences - .update({ publishAgentActivity: true, holdWebhooksWhileOffline: true }) - .pipe(Effect.flip); - assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" }); - assert.deepEqual(relayCalls, []); - assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false"); - }), - ), -); - it.effect("two overlapping updates are applied one after the other", () => { let release!: () => void; let started!: () => void; @@ -230,20 +217,6 @@ it.effect("two overlapping updates are applied one after the other", () => { ); }); -it.effect("changes nothing when the current hold setting can't be read", () => - withService({ failHoldRead: true, failHoldWrite: true }, ({ preferences, stored, relayCalls }) => - Effect.gen(function* () { - stored.set(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET, encode("true")); - const error = yield* preferences - .update({ publishAgentActivity: true, holdWebhooksWhileOffline: false }) - .pipe(Effect.flip); - assert.deepInclude(error, { _tag: "CloudLinkInternalError", operation: "read-preferences" }); - assert.deepEqual(relayCalls, []); - assert.equal(new TextDecoder().decode(stored.get(PUBLISH_AGENT_ACTIVITY_SECRET)), "false"); - }), - ), -); - it.effect("an update the client walks away from still finishes", () => { let release!: () => void; let started!: () => void; diff --git a/apps/server/src/cloud/ConnectReachability.test.ts b/apps/server/src/cloud/ConnectReachability.test.ts index daf838c4748b..28735b87378f 100644 --- a/apps/server/src/cloud/ConnectReachability.test.ts +++ b/apps/server/src/cloud/ConnectReachability.test.ts @@ -1,14 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off - reads the user guide to check its anchors. import * as NodeFS from "node:fs"; -import { formatConnectReachable, T3_CONNECT_TROUBLESHOOTING_SECTIONS } from "@t3tools/contracts"; +import { T3_CONNECT_TROUBLESHOOTING_SECTIONS } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import { - classifyReachability, - formatReachabilityFailure, - type ReachabilityFacts, -} from "./ConnectReachability.ts"; +import { classifyReachability, type ReachabilityFacts } from "./ConnectReachability.ts"; const checkedAt = "2026-10-19T00:00:00.000Z"; const connected = { readyConnections: 4, hostname: "env.example.test", protocol: "quic" }; @@ -23,44 +19,9 @@ const facts = (overrides: Partial): ReachabilityFacts => ({ }); describe("classifyReachability", () => { - it("blames the exit node when the tunnel edge times out through it", () => { - const result = classifyReachability( - facts({ - connector: notConnected, - edgeReachable: false, - exitNode: { name: "home-pi", online: true }, - }), - checkedAt, - ); - - expect(result.status === "unreachable" && formatReachabilityFailure(result)).toBe( - [ - "✕ T3 Connect is NOT reachable from other devices", - " Cloudflare's tunnel port (7844) times out from this machine.", - ' Tailscale exit node "home-pi" is on. Traffic to Cloudflare goes through it.', - " Fix: tailscale set --exit-node= (or allow outbound 7844 on that network)", - " More info: https://github.com/pingdotgg/t3code/blob/main/docs/user/t3-connect-troubleshooting.md#port-7844-is-blocked-on-the-host", - ].join("\n"), - ); - }); - - it("names a generic blocker when no exit node is on", () => { - const result = classifyReachability( - facts({ connector: notConnected, edgeReachable: false }), - checkedAt, - ); - - expect(result).toMatchObject({ - cause: "edge_unreachable", - hint: "A VPN, proxy, or firewall may block it.", - }); - }); - it.each([ [{ kind: "status", status: 502 }, "origin_unreachable"], [{ kind: "status", status: 530 }, "no_connector"], - [{ kind: "status", status: 404 }, "unexpected_response"], - [{ kind: "dns_failed" }, "dns_failed"], [{ kind: "network_error" }, "timeout"], ] as const)("maps endpoint result %o to %s", (endpoint, cause) => { expect(classifyReachability(facts({ endpoint, edgeReachable: true }), checkedAt)).toMatchObject( @@ -104,31 +65,6 @@ describe("classifyReachability", () => { classifyReachability(facts({ connector: notConnected, edgeReachable: true }), checkedAt), ).toMatchObject({ cause: "connector_not_ready" }); }); - - it("reports connections and protocol when the endpoint answers", () => { - expect( - classifyReachability(facts({ endpoint: { kind: "status", status: 200 } }), checkedAt), - ).toEqual({ status: "reachable", checkedAt, readyConnections: 4, protocol: "quic" }); - }); - - it("leaves out the connection count when cloudflared did not report one", () => { - const result = classifyReachability( - facts({ - connector: { readyConnections: null, hostname: "env.example.test", protocol: null }, - endpoint: { kind: "status", status: 200 }, - }), - checkedAt, - ); - expect(result.status === "reachable" && formatConnectReachable(result)).toBe( - "Tunnel connected", - ); - }); - - it("does not probe a tunnel that was never set up", () => { - expect( - classifyReachability(facts({ managedTunnelConfigured: false, connector: null }), checkedAt), - ).toMatchObject({ cause: "not_linked" }); - }); }); describe("T3 Connect troubleshooting links", () => { diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 246aca55cd7c..3b9d55ce3f6c 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -13,7 +12,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; +import { HttpClient } from "effect/http"; import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; @@ -93,33 +92,6 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { - it("retries connector startup failures but stops for unsupported runtimes", () => { - expect( - ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ - status: "failed", - failure: "not-installed", - reason: "The relay client is not installed.", - }), - ).toBe(true); - expect( - ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ - status: "failed", - failure: "spawn-failed", - reason: "spawn failed", - }), - ).toBe(true); - expect( - ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ - status: "failed", - failure: "unsupported-platform", - reason: "Relay client is unsupported on linux-arm.", - }), - ).toBe(false); - expect( - ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ status: "unsupported" }), - ).toBe(false); - }); - it.effect("serializes updates to persisted cloud link state", () => Effect.gen(function* () { const firstEntered = yield* Deferred.make(); @@ -231,36 +203,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("signals each registered tunnel connection", () => - Effect.gen(function* () { - const output = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ - pid: 700, - onKill: () => {}, - output: Stream.fromQueue(output), - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - tunnelId: "tunnel-1", - }); - yield* Queue.offer( - output, - new TextEncoder().encode( - "2026-10-04T06:30:43Z INF Registered tunnel connection connIndex=0 event=0\n", - ), - ); - expect(Option.isSome(yield* Stream.runHead(runtime.tunnelConnected))).toBe(true); - }), - ); - const tunnelConfig = { providerKind: "cloudflare_tunnel" as const, connectorToken: "token", @@ -346,53 +288,6 @@ describe("CloudManagedEndpointRuntime", () => { return { spawner, spawned, killed, exits, started, emit, observe }; }); - it.effect("falls back after 30 recent QUIC stalls across exits, until the runtime restarts", () => - Effect.gen(function* () { - const fixture = yield* makeTransportSpawner(); - const runtime = yield* buildCloudManagedEndpointRuntime(fixture.spawner); - yield* runtime.applyConfig(tunnelConfig); - expect(yield* Queue.take(fixture.started)).toBe(0); - expect(fixture.spawned[0]!.args).not.toContain("--protocol"); - - yield* fixture.observe(runtime, 0, quicDialWarning.repeat(20)); - yield* TestClock.adjust(Duration.minutes(4)); - yield* fixture.observe(runtime, 0, quicDatagramWarning.repeat(9)); - yield* TestClock.adjust(Duration.minutes(1)); - // The first 20 have expired, but the newer nine must survive this restart. - yield* Deferred.succeed(fixture.exits[0]!, ChildProcessSpawner.ExitCode(1)); - expect(yield* Queue.take(fixture.started)).toBe(1); - yield* fixture.observe(runtime, 1, quicDialWarning.repeat(20)); - yield* fixture.observe( - runtime, - 1, - '2026-10-16T20:30:00Z ERR Register tunnel error from server side error="context deadline exceeded" connIndex=0\n'.repeat( - 30, - ), - ); - expect(fixture.spawned).toHaveLength(2); - expect(fixture.spawned[1]!.args).not.toContain("--protocol"); - - yield* fixture.emit(1, quicDatagramWarning); - expect(yield* Queue.take(fixture.started)).toBe(2); - yield* fixture.observe(runtime, 2); - expect(fixture.killed).toEqual([800, 801]); - expect(fixture.spawned[2]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); - - yield* TestClock.adjust(Duration.seconds(30)); - yield* Deferred.succeed(fixture.exits[2]!, ChildProcessSpawner.ExitCode(1)); - expect(yield* Queue.take(fixture.started)).toBe(3); - yield* fixture.observe(runtime, 3); - expect(fixture.spawned[3]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); - yield* runtime.applyConfig(null); - expect(fixture.killed).toEqual([800, 801, 802, 803]); - - const freshRuntime = yield* buildCloudManagedEndpointRuntime(fixture.spawner); - yield* freshRuntime.applyConfig(tunnelConfig); - expect(yield* Queue.take(fixture.started)).toBe(4); - expect(fixture.spawned[4]!.args).not.toContain("--protocol"); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("preserves an explicit transport choice despite repeated QUIC stalls", () => Effect.gen(function* () { const fixture = yield* makeTransportSpawner({ transport: "quic" }); @@ -550,164 +445,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("requests recovery while the connector never registers a connection", () => - Effect.gen(function* () { - const output = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ - pid: 800, - onKill: () => {}, - output: Stream.fromQueue(output), - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - const requests = yield* Queue.unbounded(); - yield* runtime.recoveryRequests.pipe( - Stream.runForEach((config) => Queue.offer(requests, config)), - Effect.forkChild, - ); - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "token", - tunnelId: "silently-deleted", - }; - yield* runtime.applyConfig(config); - - yield* TestClock.adjust(Duration.minutes(2)); - expect(yield* Queue.size(requests)).toBe(0); - yield* TestClock.adjust(Duration.minutes(1)); - expect(yield* Queue.take(requests)).toEqual(config); - yield* TestClock.adjust(Duration.minutes(3)); - expect(yield* Queue.take(requests)).toEqual(config); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("does not request recovery once the connector has connected", () => - Effect.gen(function* () { - const output = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ - pid: 801, - onKill: () => {}, - output: Stream.fromQueue(output), - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - const requests = yield* Queue.unbounded(); - yield* runtime.recoveryRequests.pipe( - Stream.runForEach((config) => Queue.offer(requests, config)), - Effect.forkChild, - ); - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - tunnelId: "tunnel-1", - }); - yield* Queue.offer( - output, - new TextEncoder().encode( - "2026-10-06T00:00:00Z INF Registered tunnel connection connIndex=0\n", - ), - ); - yield* Stream.runHead(runtime.tunnelConnected); - - yield* TestClock.adjust(Duration.minutes(10)); - expect(yield* Queue.size(requests)).toBe(0); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect( - "restarts a running connector whose readiness check keeps reporting no connections", - () => - Effect.gen(function* () { - const spawned: Array = []; - const killed: Array = []; - const exits: Array> = []; - const secondSpawned = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const index = spawned.length; - const pid = 1000 + index; - const exit = yield* Deferred.make(); - spawned.push(pid); - exits.push(exit); - if (index === 1) yield* Deferred.succeed(secondSpawned, undefined); - const handle = makeHandle({ - pid, - exitCode: Deferred.await(exit), - onKill: () => { - if (!killed.includes(pid)) killed.push(pid); - Deferred.doneUnsafe(exit, Exit.succeed(ChildProcessSpawner.ExitCode(143))); - }, - output: Stream.concat( - Stream.make( - new TextEncoder().encode( - `2026-10-14T08:49:04Z INF Starting metrics server on 127.0.0.1:${40000 + index}/metrics\n`, - ), - ), - Stream.never, - ), - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const answers: Array = []; - const checks = yield* Queue.unbounded<{ readonly url: string; readonly killed: string }>(); - const httpClient = HttpClient.make((request) => { - Queue.offerUnsafe(checks, { url: request.url, killed: killed.join(",") }); - const status = answers.shift() ?? 503; - return status === "refused" - ? Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request }), - }), - ) - : Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); - }); - const nextCheck = Effect.gen(function* () { - for (let second = 0; second < 60; second += 1) { - yield* TestClock.adjust(Duration.seconds(1)); - const check = yield* Queue.poll(checks); - if (Option.isSome(check)) return check.value; - } - return yield* Effect.die("No readiness check within a minute"); - }); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - layerRelayClientAvailable, - httpClient, - ); - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - tunnelId: "tunnel-1", - }); - - answers.push(503, 503, 503, 503, 503, "refused", 200); - for (let check = 0; check < 13; check += 1) { - expect(yield* nextCheck).toEqual({ url: "http://127.0.0.1:40000/ready", killed: "" }); - } - yield* Deferred.await(secondSpawned); - expect(killed).toEqual([1000]); - expect(spawned).toEqual([1000, 1001]); - - for (let check = 0; check < 12; check += 1) { - expect(yield* nextCheck).toEqual({ url: "http://127.0.0.1:40001/ready", killed: "1000" }); - } - yield* Deferred.await(exits[1]!); - expect(killed).toEqual([1000, 1001]); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; @@ -791,76 +528,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("stops an active connector when a non-Cloudflare runtime config is applied", () => - Effect.gen(function* () { - const killed: Array = []; - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ - pid: 200, - onKill: () => { - killed.push(200); - }, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - - const started = yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - }); - const unsupported = yield* runtime.applyConfig({ - providerKind: "manual", - connectorToken: "manual-token", - }); - - expect(started.status).toBe("running"); - expect(unsupported).toEqual({ status: "unsupported", providerKind: "manual" }); - expect(killed).toEqual([200]); - }), - ); - - it.effect("restarts the connector when the active process has exited", () => - Effect.gen(function* () { - const spawned: Array = []; - const killed: Array = []; - let firstRunning = true; - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const pid = spawned.length === 0 ? 300 : 301; - spawned.push(pid); - const handle = makeHandle({ - pid, - isRunning: () => (pid === 300 ? firstRunning : true), - onKill: () => { - killed.push(pid); - }, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "token", - tunnelId: "tunnel-1", - }; - - const first = yield* runtime.applyConfig(config); - firstRunning = false; - const second = yield* runtime.applyConfig(config); - - expect(first).toMatchObject({ status: "running", pid: 300 }); - expect(second).toMatchObject({ status: "running", pid: 301 }); - expect(spawned).toEqual([300, 301]); - expect(killed).toEqual([300]); - }), - ); - it.effect("supervises the active connector and restarts it after process exit", () => Effect.gen(function* () { const spawned: Array = []; @@ -904,145 +571,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - const makeCrashLoopSpawner = (basePid: number, slots: number) => - Effect.gen(function* () { - const spawned: Array = []; - const exits: Array> = []; - const spawnSignals: Array> = []; - for (let index = 0; index < slots; index += 1) { - exits.push(yield* Deferred.make()); - spawnSignals.push(yield* Deferred.make()); - } - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const index = spawned.length; - spawned.push(basePid + index); - yield* Deferred.succeed(spawnSignals[index]!, undefined); - const handle = makeHandle({ - pid: basePid + index, - exitCode: Deferred.await(exits[index]!), - onKill: () => {}, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - return { spawner, spawned, exits, spawnSignals }; - }); - - it.effect("backs off restarts while the connector crash-loops", () => - Effect.gen(function* () { - const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(600, 4); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - tunnelId: "tunnel-1", - }); - expect(spawned).toEqual([600]); - - yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[1]!); - expect(spawned).toEqual([600, 601]); - - yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); - yield* TestClock.adjust(Duration.millis(999)); - expect(spawned).toEqual([600, 601]); - yield* TestClock.adjust(Duration.millis(1)); - yield* Deferred.await(spawnSignals[2]!); - expect(spawned).toEqual([600, 601, 602]); - - yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); - yield* TestClock.adjust(Duration.millis(1999)); - expect(spawned).toEqual([600, 601, 602]); - yield* TestClock.adjust(Duration.millis(1)); - yield* Deferred.await(spawnSignals[3]!); - expect(spawned).toEqual([600, 601, 602, 603]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("resets the backoff after the connector runs stably", () => - Effect.gen(function* () { - const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(700, 5); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - }); - - yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[1]!); - - yield* TestClock.adjust(Duration.millis(30_000)); - yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[2]!); - yield* Deferred.succeed(exits[2]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[3]!); - - yield* Deferred.succeed(exits[3]!, ChildProcessSpawner.ExitCode(1)); - yield* TestClock.adjust(Duration.millis(999)); - expect(spawned).toEqual([700, 701, 702, 703]); - yield* TestClock.adjust(Duration.millis(1)); - yield* Deferred.await(spawnSignals[4]!); - expect(spawned).toEqual([700, 701, 702, 703, 704]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("a recovery that returns the same config keeps the crash backoff", () => - Effect.gen(function* () { - const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(900, 4); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "same-token", - tunnelId: "same-tunnel", - }; - yield* runtime.recoveryRequests.pipe( - Stream.runForEach((requested) => runtime.applyConfig(requested).pipe(Effect.asVoid)), - Effect.forkChild, - ); - - yield* runtime.applyConfig(config); - yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[1]!); - expect(spawned).toEqual([900, 901]); - - yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); - yield* TestClock.adjust(Duration.millis(999)); - expect(spawned).toEqual([900, 901]); - yield* TestClock.adjust(Duration.millis(1)); - yield* Deferred.await(spawnSignals[2]!); - expect(spawned).toEqual([900, 901, 902]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("an explicit config change clears the backoff and preempts a delayed restart", () => - Effect.gen(function* () { - const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(800, 3); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - - yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token-1", - }); - yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); - yield* Deferred.await(spawnSignals[1]!); - - yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); - const status = yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token-2", - }); - expect(status).toMatchObject({ status: "running", pid: 802 }); - expect(spawned).toEqual([800, 801, 802]); - - yield* TestClock.adjust(Duration.millis(60_000)); - expect(spawned).toEqual([800, 801, 802]); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("serializes concurrent connector config changes", () => Effect.gen(function* () { const spawned: Array = []; @@ -1093,69 +621,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("reports connector spawn failures", () => - Effect.gen(function* () { - const spawner = ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "cloudflared missing", - }), - ), - ); - const runtime = yield* buildCloudManagedEndpointRuntime(spawner); - - const status = yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - tunnelId: "tunnel-1", - }); - - expect(status).toMatchObject({ - status: "failed", - providerKind: "cloudflare_tunnel", - tunnelId: "tunnel-1", - }); - }), - ); - - it.effect("reports a missing relay client executable without spawning", () => - Effect.gen(function* () { - const spawn = vi.fn(); - const spawner = ChildProcessSpawner.make(spawn); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.die("unused"), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - - const status = yield* runtime.applyConfig({ - providerKind: "cloudflare_tunnel", - connectorToken: "token", - }); - - expect(status).toEqual({ - status: "failed", - providerKind: "cloudflare_tunnel", - failure: "not-installed", - reason: "The relay client is not installed.", - }); - expect(spawn).not.toHaveBeenCalled(); - }), - ); - it.effect( "runs an older relay client while the pinned one installs, then hands off to it and prunes", () => @@ -1289,144 +754,6 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("leaves a PATH relay client alone", () => - Effect.gen(function* () { - const install = vi.fn(() => Effect.die("unexpected install")); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ pid: 8, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "available", - executablePath: "/opt/homebrew/bin/cloudflared", - source: "path", - version: "2026.10.0", - }), - install: Effect.suspend(install), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - expect( - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), - ).toMatchObject({ status: "running", pid: 8 }); - yield* TestClock.adjust(Duration.minutes(30)); - expect(install).not.toHaveBeenCalled(); - }), - ); - - it.effect("leaves an override relay client alone", () => - Effect.gen(function* () { - const install = vi.fn(() => Effect.die("unexpected install")); - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = makeHandle({ pid: 4, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "available", - executablePath: "/opt/cloudflared", - source: "override", - version: "2025.10.0", - }), - install: Effect.suspend(install), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - expect( - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), - ).toMatchObject({ status: "running", pid: 4 }); - yield* Effect.yieldNow; - expect(install).not.toHaveBeenCalled(); - }), - ); - - it.effect("retries a failed background install while the older connector keeps running", () => - Effect.gen(function* () { - const olderClient = { - status: "available", - executablePath: "/managed/2025.9.0/cloudflared", - source: "managed", - version: "2025.9.0", - } as const; - const pinnedClient = { - status: "available", - executablePath: "/managed/pinned/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, - } as const; - let installed = false; - let installAttempts = 0; - const spawned = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - yield* Queue.offer( - spawned, - ChildProcess.isStandardCommand(command) ? command.command : "", - ); - const handle = makeHandle({ pid: 5, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), - install: Effect.suspend(() => { - installAttempts += 1; - if (installAttempts === 1) { - return Effect.sleep(Duration.seconds(30)).pipe( - Effect.andThen( - Effect.fail( - new RelayClient.RelayClientInstallError({ - reason: "download_failed", - message: "offline", - }), - ), - ), - ); - } - installed = true; - return Effect.succeed(pinnedClient); - }), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); - expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); - yield* TestClock.adjust(Duration.seconds(30)); - expect(installAttempts).toBe(1); - - yield* TestClock.adjust(Duration.minutes(10)); - expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); - expect(installAttempts).toBe(2); - }), - ); - it.effect("keeps the older connector when the updated relay client fails to start", () => Effect.gen(function* () { const olderClient = { @@ -1595,184 +922,4 @@ describe("CloudManagedEndpointRuntime", () => { expect(yield* Queue.take(spawned)).toBe(RelayClient.CLOUDFLARED_VERSION); }), ); - - it.effect("retries a failed install when no relay client is installed", () => - Effect.gen(function* () { - const pinnedClient = { - status: "available", - executablePath: "/managed/pinned/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, - } as const; - let installed = false; - let installAttempts = 0; - const spawned = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - yield* Deferred.succeed( - spawned, - ChildProcess.isStandardCommand(command) ? command.command : "", - ); - const handle = makeHandle({ pid: 9, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.sync(() => - installed - ? pinnedClient - : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, - ), - install: Effect.suspend(() => { - installAttempts += 1; - if (installAttempts === 1) { - return Effect.fail( - new RelayClient.RelayClientInstallError({ - reason: "download_failed", - message: "offline", - }), - ); - } - installed = true; - return Effect.succeed(pinnedClient); - }), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - - expect( - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), - ).toMatchObject({ status: "failed", failure: "not-installed" }); - yield* Effect.yieldNow; - expect(installAttempts).toBe(1); - - yield* TestClock.adjust(Duration.minutes(10)); - expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); - expect(installAttempts).toBe(2); - }), - ); - - it.effect("restarts on the pin when the first post-install probe failed", () => - Effect.gen(function* () { - const olderClient = { - status: "available", - executablePath: "/managed/2025.9.0/cloudflared", - source: "managed", - version: "2025.9.0", - } as const; - const pinnedClient = { - status: "available", - executablePath: "/managed/pinned/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, - } as const; - let installed = false; - let missedProbes = 0; - const spawned = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - yield* Queue.offer( - spawned, - ChildProcess.isStandardCommand(command) ? command.command : "", - ); - const handle = makeHandle({ pid: 10, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.sync(() => { - if (installed && missedProbes === 0) { - missedProbes += 1; - return olderClient; - } - return installed ? pinnedClient : olderClient; - }), - install: Effect.sync(() => { - installed = true; - return pinnedClient; - }), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); - expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); - yield* Effect.yieldNow; - expect(Option.isNone(yield* Queue.poll(spawned))).toBe(true); - - yield* TestClock.adjust(Duration.minutes(10)); - expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); - }), - ); - - it.effect("starts the connector when the pin landed but its first probe failed", () => - Effect.gen(function* () { - const pinnedClient = { - status: "available", - executablePath: "/managed/pinned/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, - } as const; - const missing = { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }; - let installed = false; - let missedProbes = 0; - const spawned = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - yield* Deferred.succeed( - spawned, - ChildProcess.isStandardCommand(command) ? command.command : "", - ); - const handle = makeHandle({ pid: 11, onKill: () => {} }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ); - const runtime = yield* buildCloudManagedEndpointRuntime( - spawner, - Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.sync(() => { - if (!installed) return missing; - if (missedProbes === 0) { - missedProbes += 1; - return missing; - } - return pinnedClient; - }), - install: Effect.sync(() => { - installed = true; - return pinnedClient; - }), - installWithProgress: () => Effect.die("unused"), - pruneManagedVersions: Effect.void, - }), - ), - ); - - expect( - yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), - ).toMatchObject({ status: "failed", failure: "not-installed" }); - yield* Effect.yieldNow; - expect(Option.isNone(yield* Deferred.poll(spawned))).toBe(true); - - yield* TestClock.adjust(Duration.minutes(10)); - expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); - }), - ); }); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a838b710c846..d3a8af2320c4 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -21,22 +21,6 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; -const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; -const linuxPlan = { - program: [linuxRuntime, "__service-launcher"], - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", -}; - -it("runs the pinned runtime's own executable as the systemd launcher", () => { - const unit = BootService.renderBootServiceUnit(linuxPlan); - - expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); - expect(unit).toContain("KillMode=mixed"); - expect(unit).not.toContain("node"); -}); - it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], @@ -64,67 +48,25 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); -it("survives the kernel OOM-killing a greedy agent child", () => { - const unit = BootService.renderBootServiceUnit(linuxPlan); - - expect(unit).toContain("OOMPolicy=continue"); +it.each([ + ["a unit", '/home/theo/T3 "Data"/100%\\x', BootService.renderBootServiceUnit], + [ + "a plist", + "/Users/theo/a&b ", + (plan: BootService.BootServicePlan) => + BootService.renderBootServicePlist(plan, { homeDir: "/Users/theo", environmentPath: "" }), + ], +] as const)("reads the launcher back out of %s", (_, baseDir, render) => { + const program = `${baseDir}/runtime/versions/1.2.3/t3`; + expect( + BootService.bootServiceProgramOf( + render({ program: [program, "__service-launcher"], baseDir, logPath: "/log", unitPath: "" }), + ), + ).toBe(program); }); -const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; -const macPlan = { - program: [macRuntime, "__service-launcher"], - baseDir: "/Users/theo/.t3", - logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", -}; const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; -const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; - -it("runs the pinned runtime's own executable as the launch agent", () => { - const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - - expect(plist).toContain( - ` \n ${macRuntime}\n __service-launcher\n `, - ); - expect(plist).not.toContain("node"); -}); - -it("preserves the installer's provider search path in the launch agent", () => { - const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - - expect(plist).toContain(` PATH\n ${macInstallerPath}`); -}); - -it("restarts the launch agent on the systemd cadence", () => { - const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - - expect(plist).toContain("RunAtLoad\n "); - expect(plist).toContain("KeepAlive\n "); - expect(plist).toContain("ThrottleInterval\n 5"); - expect(plist).toContain("ExitTimeOut\n 90"); -}); - -it("appends both stdio streams to the boot service log", () => { - const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - - expect(plist).toContain( - "StandardOutPath\n /Users/theo/.t3/userdata/logs/boot-service.log", - ); - expect(plist).toContain( - "StandardErrorPath\n /Users/theo/.t3/userdata/logs/boot-service.log", - ); -}); - -it("escapes XML in host paths", () => { - const plist = BootService.renderBootServicePlist( - { ...macPlan, baseDir: "/Users/theo/T3 & " }, - { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & :/usr/bin" }, - ); - - expect(plist).toContain("/Users/theo/T3 & <Co>"); - expect(plist).toContain("/Users/theo/Tools & <Scripts>:/usr/bin"); -}); const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", @@ -413,6 +355,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + // regression: #5302 prevent accidental service downgrades it.effect("reports an unknown version for invalid service state", () => Effect.gen(function* () { const { service, fs, statePath } = yield* makeHarness(); @@ -468,6 +411,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + // regression: #5302 prevent accidental service downgrades it.effect("allows an explicit downgrade", () => Effect.gen(function* () { const { service, fs, statePath } = yield* makeHarness(); @@ -652,27 +596,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("restart stops and starts an installed service, and is a no-op otherwise", () => - Effect.gen(function* () { - const { service, commands } = yield* makeHarness(); - expect(yield* service.restart).toBe("skipped"); - yield* service.install(); - commands.length = 0; - - expect(yield* service.restart).toBe("restarted"); - expect( - commands.filter( - (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), - ), - ).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user daemon-reload", - "systemctl --user enable t3code.service", - "systemctl --user restart t3code.service", - ]); - }), - ); - it.effect("restart leaves a service that serves another T3 home alone", () => Effect.gen(function* () { const { service, fs, commands, makeService } = yield* makeHarness(); @@ -687,27 +610,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("restart brings the service back when activation fails", () => - Effect.gen(function* () { - const { service, commands, control } = yield* makeHarness(); - yield* service.install(); - commands.length = 0; - control.failCommand = "systemctl --user daemon-reload"; - - const error = yield* service.restart.pipe(Effect.flip); - expect(error._tag).toBe("BootServiceCommandError"); - expect( - commands.filter( - (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), - ), - ).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user daemon-reload", - "systemctl --user restart t3code.service", - ]); - }), - ); - it.effect("hands the whole install to systemd without changing the running service", () => Effect.gen(function* () { const { fs, commands, invocations, control, makeService } = yield* makeHarness(); @@ -844,14 +746,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("fails closed on Windows", () => - Effect.gen(function* () { - const { service } = yield* makeHarness("win32"); - expect((yield* service.status).supported).toBe(false); - expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); - }), - ); - it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); @@ -888,25 +782,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("restarts the launch agent when repair fails", () => - Effect.gen(function* () { - const { service, commands, control } = yield* makeHarness("darwin"); - yield* service.install(); - const plistPath = (yield* service.status).unitPath; - commands.length = 0; - control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`; - - const error = yield* service.install().pipe(Effect.flip); - expect(error._tag).toBe("BootServiceCommandError"); - expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - "launchctl enable gui/501/com.t3tools.t3code.service", - `launchctl bootstrap gui/501 ${plistPath}`, - `launchctl bootstrap gui/501 ${plistPath}`, - ]); - }), - ); - + // regression: #8173 keep provider CLIs available in the macOS service it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("darwin", ""); @@ -994,35 +870,4 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(true); }), ); - - it.effect("restarts without overwriting a pending remote update on macOS", () => - Effect.gen(function* () { - const { service, fs, statePath, commands } = yield* makeHarness("darwin"); - yield* service.install(); - const plistPath = (yield* service.status).unitPath; - const pendingState = JSON.stringify({ - protocol: SERVICE_LAUNCHER_PROTOCOL - 1, - activeVersion: "1.2.3", - update: { - id: "remote-update", - fromVersion: "1.2.3", - targetVersion: "1.2.4", - status: "pending", - }, - }); - yield* fs.writeFileString(statePath, pendingState); - for (const allowDowngrade of [false, true]) { - commands.length = 0; - - expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( - "BootServiceUpdatePendingError", - ); - expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - `launchctl bootstrap gui/501 ${plistPath}`, - ]); - } - }), - ); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index b821d1951a83..c1be1fc2c5ef 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -53,21 +53,30 @@ function quoteSystemdValue(value: string): string { : escaped; } +const unquoteSystemdValue = (raw: string) => + (raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw + ).replaceAll("%%", "%"); + +const unescapeXmlText = (value: string) => + value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + export function bootServiceBaseDirOf(contents: string): string | undefined { const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; - if (systemd !== undefined) { - const raw = systemd.trim(); - const unquoted = - raw.startsWith('"') && raw.endsWith('"') - ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : raw; - return unquoted.replaceAll("%%", "%"); - } + if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; - if (plist !== undefined) { - return plist.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); - } - return undefined; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + +/** The executable an installed unit or plist starts: the launcher's runtime. */ +export function bootServiceProgramOf(contents: string): string | undefined { + const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd); + const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( + contents, + )?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); } export interface BootServicePlan { diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 0d0f3006a07a..9416d24703d0 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -275,84 +275,3 @@ describe("link proof over a non-loopback desktop transport", () => { expect(response.status).toBe(401); })); }); - -class ConnectTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.connect) {} - -type HealthFailure = Effect.Error< - ReturnType ->; - -const answerHealthWith = async (failure: HealthFailure) => { - const layerRoutes = HttpApiBuilder.layer(ConnectTestApi).pipe( - Layer.provide(ConnectHttp.layer), - Layer.provide( - Layer.mock(CloudLink.CloudLink)({ - answerHealthRequest: () => Effect.fail(failure), - }), - ), - Layer.provide(Layer.mock(ConnectReachability.ConnectReachability)({})), - Layer.provide(AuthHttp.layerAuthenticatedAuth), - Layer.provide(Layer.mock(EnvironmentAuth.EnvironmentAuth)({})), - Layer.provideMerge( - HttpPlatform.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(Etag.layerWeak), - ), - ), - ); - const { handler, dispose } = HttpRouter.toWebHandler(layerRoutes, { - disableLogger: true, - }); - try { - const response = await handler( - new Request("http://127.0.0.1/api/t3-connect/health", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ proof: "proof" }), - }), - ); - return { status: response.status, body: (await response.json()) as unknown }; - } finally { - await dispose(); - } -}; - -describe("connect routes", () => { - it.each([ - { - failure: new CloudLink.CloudLinkProofRejectedError({ request: "health" }), - status: 401, - body: { _tag: "EnvironmentHttpUnauthorizedError", message: "Invalid cloud health request." }, - }, - { - failure: new CloudLink.CloudLinkProofReplayedError({ request: "health" }), - status: 409, - body: { - _tag: "EnvironmentHttpConflictError", - message: "Cloud health request was already consumed.", - }, - }, - { - failure: new CloudLink.CloudLinkInternalError({ - operation: "answer-health", - cause: new Error("disk full"), - }), - status: 500, - body: { - _tag: "EnvironmentHttpInternalServerError", - message: "Could not answer cloud health request.", - }, - }, - { - failure: new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}), - status: 500, - body: { - _tag: "EnvironmentHttpInternalServerError", - message: "Cloud mint public key is not installed for this environment.", - }, - }, - ])("answers $failure._tag with HTTP $status", async ({ failure, status, body }) => { - const response = await answerHealthWith(failure); - expect(response).toEqual({ status, body }); - }); -}); diff --git a/apps/server/src/cloud/linkChecks.test.ts b/apps/server/src/cloud/linkChecks.test.ts index 34902b6938a5..50300e5f747d 100644 --- a/apps/server/src/cloud/linkChecks.test.ts +++ b/apps/server/src/cloud/linkChecks.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; -import { - isSupportedLinkProviderKind, - linkProofScopes, - parseManagedEndpointLocalOrigin, -} from "./linkChecks.ts"; +import { linkProofScopes, parseManagedEndpointLocalOrigin } from "./linkChecks.ts"; describe("parseManagedEndpointLocalOrigin", () => { it.each([ @@ -54,12 +50,6 @@ describe("link proof provider kinds", () => { origin: { localHttpHost: "127.0.0.1", localHttpPort: 7331 }, }); - it("accepts managed and manual endpoints but not t3_relay", () => { - expect(isSupportedLinkProviderKind(proofRequest("cloudflare_tunnel"))).toBe(true); - expect(isSupportedLinkProviderKind(proofRequest("manual"))).toBe(true); - expect(isSupportedLinkProviderKind(proofRequest("t3_relay"))).toBe(false); - }); - it("only claims the managed-tunnel scope for tunnel links", () => { expect(linkProofScopes(proofRequest("cloudflare_tunnel"))).toEqual([ "agent_activity_notifications", diff --git a/apps/server/src/cloud/managedTunnelStartup.test.ts b/apps/server/src/cloud/managedTunnelStartup.test.ts index 03d379a513f0..fbf4fc452c25 100644 --- a/apps/server/src/cloud/managedTunnelStartup.test.ts +++ b/apps/server/src/cloud/managedTunnelStartup.test.ts @@ -1,59 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; -import { - MANAGED_TUNNEL_RECOVERY_COOLDOWN, - makeManagedTunnelRecovery, - managedTunnelStartupAction, - nextManagedTunnelRecoveryCooldown, - relayRetrySchedule, - retryDesiredLinkReconcile, - retryManagedTunnelRegistration, -} from "./managedTunnelStartup.ts"; -import { RelayRequestError } from "./relayResponse.ts"; - -describe("managedTunnelStartupAction", () => { - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "connector-token", - tunnelId: "tunnel-1", - }; - - it("requests tunnel recovery only when the relay proves it is needed", () => { - expect( - managedTunnelStartupAction({ - wantsCliLink: true, - registration: { status: "recovery_required", config }, - }), - ).toEqual({ action: "request_recovery", config }); - }); - - it("creates a desired CLI link only when no local managed link exists", () => { - expect( - managedTunnelStartupAction({ - wantsCliLink: true, - registration: { status: "not_linked" }, - }), - ).toEqual({ action: "reconcile_link" }); - }); - - it.each(["ready", "unavailable", "revoked"] as const)( - "does not provision after a %s registration result", - (status) => { - expect( - managedTunnelStartupAction({ - wantsCliLink: true, - registration: { status }, - }), - ).toEqual({ action: "none" }); - }, - ); -}); +import { retryManagedTunnelRegistration } from "./managedTunnelStartup.ts"; describe("retryManagedTunnelRegistration", () => { it.effect("does not fall back or retry when registration is permanently rejected", () => @@ -77,261 +25,4 @@ describe("retryManagedTunnelRegistration", () => { expect(fallbacks).toBe(0); }), ); - - it.effect("stops retrying after the retry window so startup can fall back", () => - Effect.gen(function* () { - let attempts = 0; - const registration = Effect.suspend(() => { - attempts += 1; - return Effect.fail("relay unavailable" as const); - }); - const fiber = yield* Effect.forkChild( - Effect.flip(retryManagedTunnelRegistration(registration, () => true)), - { startImmediately: true }, - ); - yield* TestClock.adjust("15 minutes"); - expect(yield* Fiber.join(fiber)).toBe("relay unavailable"); - expect(attempts).toBeGreaterThan(5); - expect(attempts).toBeLessThan(60); - }), - ); - - it.effect("waits for successful registration before it activates the connector", () => - Effect.gen(function* () { - const firstAttempt = yield* Deferred.make(); - let attempts = 0; - let activations = 0; - let reconciliations = 0; - const registration = Effect.suspend(() => { - attempts += 1; - if (attempts === 1) { - return Deferred.succeed(firstAttempt, undefined).pipe( - Effect.andThen(Effect.fail("relay unavailable" as const)), - ); - } - return Effect.succeed({ status: "ready" as const }); - }); - const startup = retryManagedTunnelRegistration(registration, () => true).pipe( - Effect.tap((result) => - Effect.sync(() => { - const action = managedTunnelStartupAction({ wantsCliLink: true, registration: result }); - if (action.action === "reconcile_link") { - reconciliations += 1; - } - activations += 1; - }), - ), - ); - - const fiber = yield* Effect.forkChild(startup, { startImmediately: true }); - yield* Deferred.await(firstAttempt); - expect(attempts).toBe(1); - expect(activations).toBe(0); - - yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(fiber); - - expect(attempts).toBe(2); - expect(activations).toBe(1); - expect(reconciliations).toBe(0); - }), - ); -}); - -describe("nextManagedTunnelRecoveryCooldown", () => { - const minutes = (value: number) => Duration.minutes(value); - - it("backs off while recoveries keep failing to fix the tunnel, up to 30 minutes", () => { - let cooldown = MANAGED_TUNNEL_RECOVERY_COOLDOWN; - const seen: Array = []; - for (let attempt = 0; attempt < 6; attempt++) { - cooldown = nextManagedTunnelRecoveryCooldown({ - current: cooldown, - sinceLastRecovery: minutes(3), - }); - seen.push(Duration.toMinutes(cooldown)); - } - expect(seen).toEqual([4, 8, 16, 30, 30, 30]); - }); - - it("starts over once the previous recovery held", () => { - expect( - Duration.toMinutes( - nextManagedTunnelRecoveryCooldown({ current: minutes(16), sinceLastRecovery: minutes(26) }), - ), - ).toBe(2); - expect( - Duration.toMinutes( - nextManagedTunnelRecoveryCooldown({ current: minutes(16), sinceLastRecovery: minutes(20) }), - ), - ).toBe(30); - }); -}); - -describe("relayRetrySchedule", () => { - it.effect("waits at least as long as the relay's Retry-After", () => - Effect.gen(function* () { - let attempts = 0; - const request = Effect.suspend(() => { - attempts += 1; - return attempts === 1 - ? Effect.fail( - new RelayRequestError({ - rejection: "unavailable", - description: "paused", - retryAfterMillis: 120_000, - }), - ) - : Effect.succeed("done"); - }); - const fiber = yield* Effect.forkChild( - Effect.retry(request, { schedule: relayRetrySchedule }), - { - startImmediately: true, - }, - ); - yield* TestClock.adjust("90 seconds"); - expect(attempts).toBe(1); - yield* TestClock.adjust("31 seconds"); - expect(yield* Fiber.join(fiber)).toBe("done"); - expect(attempts).toBe(2); - }), - ); -}); - -describe("makeManagedTunnelRecovery", () => { - const config = { - providerKind: "cloudflare_tunnel" as const, - connectorToken: "connector-token", - tunnelId: "tunnel-1", - }; - - it.effect("retries an unavailable relay every cooldown, and cools down from when it ends", () => - Effect.gen(function* () { - const attemptMinutes: Array = []; - const recover = makeManagedTunnelRecovery({ - recover: () => - Effect.gen(function* () { - attemptMinutes.push((yield* Clock.currentTimeMillis) / 60_000); - return attemptMinutes.length < 4 - ? yield* Effect.fail("relay unavailable" as const) - : true; - }), - isRetryable: () => true, - }); - const run = yield* recover; - const fiber = yield* Effect.forkChild(run(config), { startImmediately: true }); - yield* TestClock.adjust("6 minutes"); - expect(yield* Fiber.join(fiber)).toBe(true); - expect(attemptMinutes.map(Math.round)).toEqual([0, 2, 4, 6]); - - yield* TestClock.adjust("1 minute"); - const next = yield* Effect.forkChild(run(config), { startImmediately: true }); - yield* TestClock.adjust("10 minutes"); - yield* Fiber.join(next); - expect(attemptMinutes.map(Math.round)).toEqual([0, 2, 4, 6, 10]); - }), - ); -}); - -describe("retryManagedTunnelRegistration fallback", () => { - it.effect("falls back by the end of the window and keeps honoring Retry-After", () => - Effect.gen(function* () { - let fallbackAtMillis: number | undefined; - const attemptMillis: Array = []; - const registration = Clock.currentTimeMillis.pipe( - Effect.tap((now) => Effect.sync(() => attemptMillis.push(now))), - Effect.andThen( - Effect.fail( - new RelayRequestError({ - rejection: "unavailable", - description: "paused", - retryAfterMillis: 900_000, - }), - ), - ), - ); - yield* Effect.forkChild( - retryManagedTunnelRegistration( - registration, - () => true, - Clock.currentTimeMillis.pipe( - Effect.tap((now) => - Effect.sync(() => { - fallbackAtMillis ??= now; - }), - ), - Effect.asVoid, - ), - ), - { startImmediately: true }, - ); - yield* TestClock.adjust("1 hour"); - expect(fallbackAtMillis).toBeDefined(); - expect(fallbackAtMillis).toBeLessThanOrEqual(10 * 60_000); - expect(attemptMillis.length).toBeGreaterThan(2); - for (let index = 1; index < attemptMillis.length; index++) { - expect(attemptMillis[index]! - attemptMillis[index - 1]!).toBeGreaterThanOrEqual(900_000); - } - }), - ); -}); - -describe("retryDesiredLinkReconcile", () => { - const relayUnavailable = new RelayRequestError({ - rejection: "unavailable", - description: "relay unavailable", - }); - - it.effect("gives up on a local failure once the retry window ends", () => - Effect.gen(function* () { - let attempts = 0; - let windowLogs = 0; - const fiber = yield* Effect.forkChild( - Effect.flip( - retryDesiredLinkReconcile( - Effect.suspend(() => { - attempts += 1; - return Effect.fail("cloudflared cannot run" as const); - }), - () => true, - () => - Effect.sync(() => { - windowLogs += 1; - }), - ), - ), - { startImmediately: true }, - ); - yield* TestClock.adjust("2 hours"); - expect(yield* Fiber.join(fiber)).toBe("cloudflared cannot run"); - expect(attempts).toBeLessThan(20); - expect(windowLogs).toBe(0); - }), - ); - - it.effect("keeps relinking while the relay is down and succeeds when it returns", () => - Effect.gen(function* () { - let windowLogs = 0; - const reconcile = Effect.gen(function* () { - return (yield* Clock.currentTimeMillis) < 30 * 60_000 - ? yield* Effect.fail(relayUnavailable) - : "managed"; - }); - const fiber = yield* Effect.forkChild( - retryDesiredLinkReconcile( - reconcile, - () => true, - () => - Effect.sync(() => { - windowLogs += 1; - }), - ), - { startImmediately: true }, - ); - yield* TestClock.adjust("45 minutes"); - expect(yield* Fiber.join(fiber)).toBe("managed"); - expect(windowLogs).toBe(1); - }), - ); }); diff --git a/apps/server/src/cloud/managedTunnelStartup.ts b/apps/server/src/cloud/managedTunnelStartup.ts index 7101a19b19f7..a3eb6df24a6a 100644 --- a/apps/server/src/cloud/managedTunnelStartup.ts +++ b/apps/server/src/cloud/managedTunnelStartup.ts @@ -50,7 +50,7 @@ const MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW = Duration.minutes(10); // retry has to re-run the whole operation. const RELAY_RETRY_MAX_DELAY = Duration.minutes(5); -export const relayRetrySchedule = Schedule.exponential("1 second").pipe( +const relayRetrySchedule = Schedule.exponential("1 second").pipe( Schedule.jittered, Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, RELAY_RETRY_MAX_DELAY)), @@ -132,7 +132,7 @@ export const retryDesiredLinkReconcile = ( // Every managed host shares one relay, so a host stuck in a bad loop must not // turn into a fleet-wide request storm. -export const MANAGED_TUNNEL_RECOVERY_COOLDOWN = Duration.minutes(2); +const MANAGED_TUNNEL_RECOVERY_COOLDOWN = Duration.minutes(2); // Retries inside one recovery, while the relay keeps failing, stay at least a // cooldown apart. They do not grow the cooldown: only a new request does. @@ -152,7 +152,7 @@ const MANAGED_TUNNEL_RECOVERY_SETTLED_AFTER = Duration.minutes(10); * and recovery cannot help, so each request that follows right after a * recovery doubles the wait, up to 30 minutes. */ -export function nextManagedTunnelRecoveryCooldown(input: { +function nextManagedTunnelRecoveryCooldown(input: { readonly current: Duration.Duration; readonly sinceLastRecovery: Duration.Duration; }): Duration.Duration { diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 0556425ef21c..9d536f471e76 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -8,8 +8,11 @@ import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -17,7 +20,9 @@ import { pinnedRuntimePaths, PinnedRuntimeInstallError, type PinnedRuntimeProgress, + prunePinnedRuntimes, } from "./pinnedRuntime.ts"; +import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; const version = "1.2.3"; const archiveName = `t3-${version}-linux-x64.tar.gz`; @@ -97,73 +102,6 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); - it.effect.each([true, false])( - "reports bytes before completion, then verifies and extracts (known size: %s)", - (knownSize) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-" }); - const firstChunk = yield* Deferred.make(); - let archiveController: ReadableStreamDefaultController | undefined; - const checksums = yield* validChecksums; - const progress: PinnedRuntimeProgress[] = []; - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - request.url.endsWith("/SHA256SUMS") - ? new Response(checksums) - : new Response( - new ReadableStream({ - start(controller) { - archiveController = controller; - controller.enqueue(archiveBytes.slice(0, 4)); - }, - }), - { headers: knownSize ? { "content-length": String(archiveBytes.length) } : {} }, - ), - ), - ), - ); - const install = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: client, - runner: extractingRunner(fs, path), - validate: () => Effect.void, - onProgress: (event) => { - progress.push(event); - if (event.stage === "download" && event.received === 4) { - Deferred.doneUnsafe(firstChunk, Effect.void); - } - }, - }).pipe(Effect.forkScoped); - yield* Deferred.await(firstChunk); - assert.deepEqual(progress.at(-1), { - stage: "download", - received: 4, - total: knownSize ? archiveBytes.length : undefined, - }); - assert.isFalse(progress.some((event) => event.stage === "extract")); - assert.isDefined(archiveController); - archiveController!.enqueue(archiveBytes.slice(4)); - archiveController!.close(); - const installed = yield* Fiber.join(install); - assert.deepEqual(progress.slice(-4), [ - { stage: "download", received: archiveBytes.length, total: archiveBytes.length }, - { stage: "verify" }, - { stage: "extract" }, - { stage: "validate" }, - ]); - assert.equal(yield* fs.readFileString(installed.sentinelPath), `${version}\n`); - }), - ); - it.effect("cleans up an interrupted download without reporting verification or extraction", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -217,165 +155,6 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); - it.effect.each([true, false])( - "installs a download receiving bytes for over ten minutes (progress: %s)", - (reportProgress) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-slow-" }); - const checksums = yield* validChecksums; - const reads = yield* Effect.forEach(archiveBytes, () => Deferred.make()); - let controller: ReadableStreamDefaultController; - let offset = 0; - const body = new ReadableStream( - { - start(value) { - controller = value; - }, - pull() { - Deferred.doneUnsafe(reads[offset++]!, Effect.void); - }, - }, - { highWaterMark: 0 }, - ); - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(request.url.endsWith("/SHA256SUMS") ? checksums : body), - ), - ), - ); - const progress: PinnedRuntimeProgress[] = []; - yield* Effect.gen(function* () { - // Each read is demanded by the consumer, with no prefetched chunks. - for (let index = 0; index < archiveBytes.length; index++) { - yield* Deferred.await(reads[index]!); - yield* TestClock.adjust(Duration.seconds(40)); - controller.enqueue(archiveBytes.slice(index, index + 1)); - } - controller.close(); - }).pipe(Effect.forkScoped); - const installed = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: client, - runner: extractingRunner(fs, path), - validate: () => Effect.void, - ...(reportProgress - ? { onProgress: (event: PinnedRuntimeProgress) => progress.push(event) } - : {}), - }); - assert.equal(yield* fs.readFileString(installed.sentinelPath), `${version}\n`); - if (reportProgress) { - assert.deepEqual(progress.slice(-4), [ - { stage: "download", received: archiveBytes.length, total: archiveBytes.length }, - { stage: "verify" }, - { stage: "extract" }, - { stage: "validate" }, - ]); - } - }), - ); - - it.effect.each([ - { stall: "response", reportProgress: false }, - { stall: "body", reportProgress: false }, - { stall: "body", reportProgress: true }, - ])( - "fails a one-minute $stall stall, cleans up and releases the lock (progress: $reportProgress)", - ({ stall, reportProgress }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-stalled-" }); - const checksums = yield* validChecksums; - const waiting = yield* Deferred.make(); - let cancelled = false; - let sentChunk = false; - const client = HttpClient.make((request) => { - if (request.url.endsWith("/SHA256SUMS")) { - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(checksums))); - } - if (stall === "response") { - return Deferred.succeed(waiting, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring( - Effect.sync(() => { - cancelled = true; - }), - ), - ); - } - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream( - { - pull(controller) { - if (!sentChunk) { - sentChunk = true; - controller.enqueue(archiveBytes.slice(0, 4)); - } else { - Deferred.doneUnsafe(waiting, Effect.void); - } - }, - cancel() { - cancelled = true; - }, - }, - { highWaterMark: 0 }, - ), - ), - ), - ); - }); - const progress: PinnedRuntimeProgress[] = []; - const commands: string[] = []; - const install = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: client, - runner: extractingRunner(fs, path, commands), - validate: () => Effect.die("must not validate a stalled archive"), - ...(reportProgress - ? { onProgress: (event: PinnedRuntimeProgress) => progress.push(event) } - : {}), - }).pipe(Effect.forkScoped); - yield* Deferred.await(waiting); - yield* TestClock.adjust(Duration.minutes(1)); - assert.isTrue(cancelled, "the stalled transfer must be cancelled after one minute"); - const error = yield* Fiber.join(install).pipe(Effect.flip); - assert.instanceOf(error, PinnedRuntimeInstallError); - assert.equal(error.step, "downloading the t3 release archive (timed out)"); - assert.deepEqual(commands, []); - assert.isTrue(progress.every((event) => event.stage === "download")); - assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); - const installed = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(checksums), - runner: extractingRunner(fs, path), - validate: () => Effect.void, - }); - assert.equal(yield* fs.readFileString(installed.sentinelPath), `${version}\n`); - }), - ); - it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -524,32 +303,206 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); }), ); +}); + +it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { + const lsofRunner = (stdout: string) => + ProcessRunner.ProcessRunner.of({ + run: () => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + + const setup = Effect.fn(function* (versions: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-prune-" }); + const versionsDir = path.join(baseDir, "runtime", "versions"); + const now = Duration.toMillis(Duration.days(1)); + yield* TestClock.setTime(now); + const install = Effect.fn(function* ( + version: string, + completed: "long ago" | "just now" | "never" = "long ago", + ) { + const directory = path.join(versionsDir, version); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(path.join(directory, "t3"), ""); + if (completed === "never") return; + const sentinel = path.join(directory, ".install-complete"); + yield* fs.writeFileString(sentinel, `${version}\n`); + const at = completed === "just now" ? now : 0; + yield* fs.utimes(sentinel, at, at); + }); + for (const version of versions) yield* install(version); + return { baseDir, versionsDir, install }; + }); - it.effect("removes staging when installation is interrupted", () => + it.effect("removes only completed runtimes nothing references", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-interrupt-" }); - const started = yield* Deferred.make(); - const runner = ProcessRunner.ProcessRunner.of({ - run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }); - const install = yield* ensurePinnedRuntimeInstalled({ + const { baseDir, versionsDir, install } = yield* setup([ + "1.0.0", + "1.1.0", + "1.2.0", + "1.2.5", + "1.3.0", + "1.3.5", + "1.4.0", + "1.5.0", + "2.0.0", + "2.1.0", + ]); + // A downgrade another process installed but has not pointed anything at yet. + yield* install("0.5.0", "just now"); + yield* install("1.0.1", "never"); + yield* install(".staging-abc", "never"); + yield* install(".pruned-0.8.0"); + yield* install("other", "never"); + yield* fs.symlink(path.join(versionsDir, "other"), path.join(versionsDir, "0.9.0")); + const binDir = path.join(baseDir, "bin"); + const customBinDir = path.join(baseDir, "custom-bin"); + yield* fs.makeDirectory(binDir); + yield* fs.makeDirectory(customBinDir); + // Chained: the launcher reaches its runtime through a second link. + yield* fs.symlink(path.join(versionsDir, "1.2.0", "t3"), path.join(baseDir, "alias-t3")); + yield* fs.symlink(path.join(baseDir, "alias-t3"), path.join(binDir, "t3")); + yield* fs.symlink(path.join(versionsDir, "1.2.5", "t3"), path.join(customBinDir, "t3")); + yield* fs.writeFileString( + path.join(baseDir, "runtime", "launchers"), + `${path.join(customBinDir, "t3")}\n`, + ); + yield* fs.writeFileString( + path.join(baseDir, "runtime", "service-state.json"), + JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.4.0", + update: { id: "u", fromVersion: "1.4.0", targetVersion: "1.5.0", status: "rolled-back" }, + }), + ); + + const removed = yield* prunePinnedRuntimes({ baseDir, - version, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(yield* validChecksums), - runner, - validate: () => Effect.void, - }).pipe(Effect.forkScoped); + keep: ["2.0.0"], + references: [`ExecStart=${path.join(versionsDir, "1.3.5", "t3")} __service-launcher`], + }).pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + lsofRunner(`p42\nftxt\nn${path.join(versionsDir, "1.3.0", "t3")}\n`), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: binDir }), + ); - yield* Deferred.await(started); - yield* Fiber.interrupt(install); - const versionsDir = path.join(baseDir, "runtime", "versions"); - assert.deepEqual(yield* fs.readDirectory(versionsDir), []); + // 1.2.0 is the default launcher, 1.2.5 a recorded one, 1.3.0 running, 1.3.5 in the unit, + // 1.4.0 active, 1.5.0 the rolled-back target, 2.0.0 the caller's, 0.5.0 just installed. + // Being newer than the caller's version does not keep 2.1.0. + assert.deepEqual(removed.toSorted(), ["0.8.0", "1.0.0", "1.1.0", "2.1.0"]); + // The launcher found on PATH is remembered for a service that cannot see it. + assert.equal( + yield* fs.readFileString(path.join(baseDir, "runtime", "launchers")), + `${path.join(customBinDir, "t3")}\n${path.join(binDir, "t3")}\n`, + ); + assert.deepEqual((yield* fs.readDirectory(versionsDir)).toSorted(), [ + ".staging-abc", + "0.5.0", + "0.9.0", + "1.0.1", + "1.2.0", + "1.2.5", + "1.3.0", + "1.3.5", + "1.4.0", + "1.5.0", + "2.0.0", + "other", + ]); + }), + ); + + it.effect("matches a Windows shim whatever its casing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); + const shim = path.join(baseDir, "t3.cmd"); + yield* fs.writeFileString( + shim, + `@echo off\r\n"${path.join(versionsDir, "1.0.0", "t3.exe").toUpperCase()}" %*`, + ); + yield* fs.writeFileString(path.join(baseDir, "runtime", "launchers"), `${shim}\n`); + + const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( + Effect.provideService( + ProcessRunner.ProcessRunner, + lsofRunner("C:\\Windows\\explorer.exe\n"), + ), + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(HostProcess.Environment, {}), + ); + assert.deepEqual(removed, ["1.1.0"]); + }), + ); + + it.effect.each([ + { name: "the process list is unavailable", state: undefined, lsof: "" }, + { name: "the service state is unparseable", state: "{", lsof: "p1\nftxt\nn/sbin/launchd\n" }, + { + name: "the service state cannot be read", + state: null, + lsof: "p1\nftxt\nn/sbin/launchd\n", + }, + ])("keeps everything when $name", ({ state, lsof }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["1.0.0"]); + const statePath = path.join(baseDir, "runtime", "service-state.json"); + if (state === null) yield* fs.makeDirectory(statePath); + else if (state !== undefined) yield* fs.writeFileString(statePath, state); + + const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, lsofRunner(lsof)), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: baseDir }), + ); + assert.deepEqual(removed, []); + assert.deepEqual(yield* fs.readDirectory(versionsDir), ["1.0.0"]); }), ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a runtime a process runs through a symlink", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); + const entry = path.join(versionsDir, "1.0.0", "t3"); + yield* fs.copyFile("/bin/sleep", entry); + yield* fs.chmod(entry, 0o755); + const link = path.join(baseDir, "t3"); + yield* fs.symlink(entry, link); + // Its argv names only the link, as with `t3 serve` run from PATH. Removing the link after + // spawning leaves the process's executable as the only reference. + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner.spawn(ChildProcess.make(link, ["30"])); + yield* fs.remove(link); + + const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, { T3CODE_INSTALL_BIN_DIR: baseDir }), + Effect.provide(ProcessRunner.layer), + ); + assert.deepEqual(removed, ["1.1.0"]); + }), + ); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 0b3592a20194..0093e7a6220e 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; @@ -19,7 +20,10 @@ import { parseChecksums, } from "@t3tools/shared/cliRelease"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + import * as ProcessRunner from "../processRunner.ts"; +import { isExactServiceVersion, parseServiceState, SERVICE_STATE_FILE } from "./serviceProtocol.ts"; const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); @@ -364,3 +368,266 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( export const ensurePinnedRuntimeInstalled = (input: PinnedRuntimeInstallInput) => pinnedRuntimeInstallLock.withPermit(installPinnedRuntime(input)); + +const PINNED_RUNTIME_PRUNED_PREFIX = ".pruned-"; +/** + * Another process may have just published a runtime it has not yet pointed anything at: an + * update, a downgrade, or the install script between unpacking and linking. The install lock only + * covers this process, so recently completed runtimes are left for a later prune. + */ +const PINNED_RUNTIME_PRUNE_GRACE = Duration.hours(1); +/** + * One `t3` launcher path per line, appended by the install scripts and by any prune that finds a + * launcher on PATH. A service does not see the installer's `T3CODE_INSTALL_BIN_DIR` or the + * interactive PATH, so this is how it finds a custom one, including installs that predate the file. + */ +const PINNED_RUNTIME_LAUNCHERS_FILE = "launchers"; + +export function launcherOwnsVersionsDir( + path: Path.Path, + versionsDir: string, + candidate: string, +): boolean { + const relative = path.relative(versionsDir, path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +/** The executable a `t3.cmd` shim written by the install script or `t3 update` runs. */ +export const windowsShimTarget = (contents: string) => /^"([^"]+)"/m.exec(contents)?.[1]; + +/** Where the `t3` launchers written by the install scripts and `t3 update` point. */ +const shellLauncherTargets = Effect.fn("cloud.pinned_runtime.shell_launcher_targets")(function* ( + baseDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const home = yield* HostProcess.HomeDirectory; + const launcherName = platform === "win32" ? "t3.cmd" : "t3"; + const launchersFile = path.join(baseDir, PINNED_RUNTIME_DIR, PINNED_RUNTIME_LAUNCHERS_FILE); + const recorded = (yield* fs.readFileString(launchersFile).pipe(Effect.orElseSucceed(() => ""))) + .split(/\r?\n/) + .filter((launcher) => launcher.trim().length > 0); + const found = [ + environment["T3CODE_INSTALL_BIN_DIR"] || path.join(home, ".local", "bin"), + ...(environment["PATH"] ?? environment["Path"] ?? "").split(platform === "win32" ? ";" : ":"), + ] + .filter((directory) => path.isAbsolute(directory)) + .map((directory) => path.join(directory, launcherName)); + const versionsDir = pinnedRuntimeVersionsDir(path, baseDir); + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + const unrecorded: Array = []; + const targets: Array = []; + for (const launcher of [...recorded, ...found]) { + const target = + platform === "win32" + ? yield* fs.readFileString(launcher).pipe( + Effect.map(windowsShimTarget), + Effect.orElseSucceed(() => undefined), + ) + : // Fully resolved, through chained links and a symlinked T3 home alike. + yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)); + if (target === undefined) continue; + targets.push(target); + if ( + (launcherOwnsVersionsDir(path, versionsDir, target) || + launcherOwnsVersionsDir(path, realVersionsDir, target)) && + !recorded.includes(launcher) && + !unrecorded.includes(launcher) + ) { + unrecorded.push(launcher); + } + } + if (unrecorded.length > 0) { + yield* fs + .writeFileString(launchersFile, unrecorded.map((launcher) => `${launcher}\n`).join(""), { + flag: "a", + }) + .pipe(Effect.ignore); + } + return targets; +}); + +/** + * Resolved executables rather than command lines: a server started through the `t3` symlink + * shows up as `t3 serve`, possibly from another T3 home that runs this home's runtimes. Windows + * has no symlink launcher, and its `t3.cmd` shim runs the absolute path, so command lines do there. + */ +const runningExecutables = Effect.fn("cloud.pinned_runtime.running_executables")(function* () { + const fs = yield* FileSystem.FileSystem; + const runner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcess.Platform; + if (platform === "linux") { + const pids = yield* fs.readDirectory("/proc").pipe(Effect.option); + if (Option.isNone(pids)) return Option.none(); + const executables: Array = []; + for (const pid of pids.value) { + if (!/^\d+$/.test(pid)) continue; + // Unreadable ones belong to other users or are this user's non-dumpable daemons (systemd + // --user, ssh-agent). Neither runs a t3 runtime, and bailing on them would never prune. + const executable = yield* fs.readLink(`/proc/${pid}/exe`).pipe(Effect.option); + if (Option.isSome(executable)) executables.push(executable.value); + } + return executables.length === 0 ? Option.none() : Option.some(executables.join("\n")); + } + const result = yield* runner + .run({ + ...(platform === "win32" + ? { + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine", + ], + } + : { command: "lsof", args: ["-nP", "-w", "-d", "txt", "-F", "n"] }), + timeout: Duration.seconds(10), + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe(Effect.option); + return Option.isSome(result) && result.value.code === 0 && result.value.stdout.length > 0 + ? Option.some(result.value.stdout) + : Option.none(); +}); + +const selectUnusedRuntimes = Effect.fn("cloud.pinned_runtime.select_unused")(function* (input: { + readonly baseDir: string; + readonly keep: ReadonlyArray; + readonly references: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const entries = yield* fs.readDirectory(versionsDir).pipe(Effect.orElseSucceed(() => [])); + // An interrupted prune has already renamed its runtime out of the way. + const pruned = new Set( + entries + .filter((entry) => entry.startsWith(PINNED_RUNTIME_PRUNED_PREFIX)) + .map((entry) => path.join(versionsDir, entry)), + ); + + const settledBefore = + (yield* Clock.currentTimeMillis) - Duration.toMillis(PINNED_RUNTIME_PRUNE_GRACE); + const completed: Array = []; + for (const version of entries) { + if (!isExactServiceVersion(version)) continue; + const versionDir = path.join(versionsDir, version); + const sentinelPath = path.join(versionDir, ".install-complete"); + const isLink = yield* fs.readLink(versionDir).pipe(Effect.isSuccess); + const sentinel = yield* fs.readFileString(sentinelPath).pipe(Effect.option); + const completedAt = yield* fs.stat(sentinelPath).pipe( + Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime()), + Effect.orElseSucceed(() => undefined), + ); + if ( + !isLink && + Option.isSome(sentinel) && + sentinel.value.trim() === version && + completedAt !== undefined && + completedAt < settledBefore + ) { + completed.push(version); + } + } + if (completed.length === 0) return pruned; + + const keep = new Set(input.keep); + const stateText = yield* fs + .readFileString(path.join(input.baseDir, PINNED_RUNTIME_DIR, SERVICE_STATE_FILE)) + .pipe( + Effect.asSome, + Effect.catch((error) => + Effect.succeed(error.reason._tag === "NotFound" ? Option.none() : undefined), + ), + ); + // State this version cannot read may reference runtimes in ways it does not know. + if (stateText === undefined) return pruned; + if (Option.isSome(stateText)) { + const state = parseServiceState(stateText.value); + if (state === undefined) return pruned; + keep.add(state.activeVersion); + if (state.update !== undefined) { + keep.add(state.update.fromVersion); + keep.add(state.update.targetVersion); + } + } + const executables = yield* runningExecutables(); + // Without a process list, a runtime in use would look unused. + if (Option.isNone(executables)) return pruned; + const platform = yield* HostProcess.Platform; + // Windows paths are case-insensitive, and shims and process lists need not agree on case. + const foldCase = (value: string) => (platform === "win32" ? value.toLowerCase() : value); + const references = [ + executables.value, + ...(yield* shellLauncherTargets(input.baseDir)), + ...input.references, + ].map(foldCase); + // Process executables are resolved, so they name the real path of a symlinked T3 home. + const realVersionsDir = yield* fs + .realPath(versionsDir) + .pipe(Effect.orElseSucceed(() => versionsDir)); + + for (const version of completed) { + const versionDir = path.join(versionsDir, version); + const prefixes = [versionDir, path.join(realVersionsDir, version)].map((dir) => + foldCase(dir + path.sep), + ); + if ( + keep.has(version) || + references.some((reference) => prefixes.some((prefix) => reference.includes(prefix))) + ) { + continue; + } + const target = path.join(versionsDir, `${PINNED_RUNTIME_PRUNED_PREFIX}${version}`); + // Renaming first means an interrupted removal never leaves a partial tree that still carries + // its sentinel and would pass for a complete install. + yield* fs.remove(target, { recursive: true, force: true }).pipe( + Effect.andThen(fs.rename(versionDir, target)), + Effect.andThen(Effect.sync(() => pruned.add(target))), + Effect.catch((cause) => + Effect.logWarning("Could not remove an unused t3 runtime.", { version, cause }), + ), + ); + } + return pruned; +}); + +/** + * Every update publishes a new immutable runtime, so without pruning they pile up. Removes + * completed runtimes nothing can start again and keeps: the caller's versions, everything the + * service state references (active, rollback, pending), and runtimes that a running process + * executes, a `t3` launcher points at, or `references` (such as the service unit) name, plus + * anything published within the last hour. Staging directories, symlinks, and unexpected entries + * are never touched. Returns the versions removed. + */ +export const prunePinnedRuntimes = Effect.fn("cloud.pinned_runtime.prune")(function* (input: { + readonly baseDir: string; + readonly keep: ReadonlyArray; + readonly references?: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Only selection and the renames race an install; deleting renamed trees can take a while. + const pruned = yield* pinnedRuntimeInstallLock.withPermit( + selectUnusedRuntimes({ ...input, references: input.references ?? [] }), + ); + const removed: Array = []; + for (const target of pruned) { + yield* fs.remove(target, { recursive: true, force: true }).pipe( + Effect.andThen( + Effect.sync(() => + removed.push(path.basename(target).slice(PINNED_RUNTIME_PRUNED_PREFIX.length)), + ), + ), + Effect.catch((cause) => + Effect.logWarning("Could not remove an unused t3 runtime.", { target, cause }), + ), + ); + } + return removed; +}); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 31d8da62909b..ca1d86b8ddad 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -13,30 +13,6 @@ import { const provideEnv = (env: Readonly>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); -it.effect("uses the statically injected relay URL when no runtime override exists", () => - Effect.gen(function* () { - const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test///").pipe( - provideEnv({}), - ); - - assert.equal(relayUrl, "https://embedded.example.test"); - }), -); - -it.effect("prefers a runtime relay URL override over the statically injected value", () => - Effect.gen(function* () { - const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test").pipe( - provideEnv({ T3CODE_RELAY_URL: "https://runtime.example.test///" }), - ); - - assert.equal(relayUrl, "https://runtime.example.test"); - }), -); - -it.effect("requires a relay URL when the server bundle has no injected value", () => - makeRelayUrlConfig("").pipe(provideEnv({}), Effect.flip), -); - it.effect("rejects an insecure runtime relay URL override", () => makeRelayUrlConfig("https://embedded.example.test").pipe( provideEnv({ T3CODE_RELAY_URL: "http://runtime.example.test" }), @@ -44,27 +20,6 @@ it.effect("rejects an insecure runtime relay URL override", () => ), ); -it.effect("rejects an injected relay URL with a non-origin path", () => - makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip), -); - -it.effect("normalizes the hosted app URL to an absolute origin", () => - Effect.gen(function* () { - assert.equal( - yield* hostedAppUrlConfig.pipe( - provideEnv({ T3CODE_HOSTED_APP_URL: "https://nightly.app.t3.codes" }), - ), - "https://nightly.app.t3.codes", - ); - assert.equal( - yield* hostedAppUrlConfig.pipe( - provideEnv({ T3CODE_HOSTED_APP_URL: "http://localhost:5733" }), - ), - "http://localhost:5733", - ); - }), -); - it.effect("rejects malformed or insecure hosted app URLs", () => Effect.gen(function* () { for (const value of [ @@ -100,87 +55,6 @@ it.effect("derives direct Clerk OAuth endpoints from statically injected public }), ); -it.effect("prefers runtime Clerk OAuth config overrides over statically injected values", () => - Effect.gen(function* () { - const config = yield* makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "pk_test_ZW1iZWRkZWQuZXhhbXBsZS50ZXN0JA==", - clerkCliOAuthClientIdFallback: "oauth_client_embedded", - }).pipe( - provideEnv({ - T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_cnVudGltZS5leGFtcGxlLnRlc3Qk", - T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_runtime", - }), - ); - - assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); - assert.equal(config.clientId, "oauth_client_runtime"); - }), -); - -it.effect("requires Clerk OAuth config when the server bundle has no injected values", () => - makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "", - clerkCliOAuthClientIdFallback: "", - }).pipe(provideEnv({}), Effect.flip), -); - -it.effect("reports malformed Clerk publishable keys as typed configuration failures", () => - Effect.gen(function* () { - const result = yield* makeCloudCliOAuthConfig({ - clerkPublishableKeyFallback: "pk_test_not-base64!!", - clerkCliOAuthClientIdFallback: "oauth_client_embedded", - }).pipe(provideEnv({}), Effect.result); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) { - assert.equal(result.failure.cause._tag, "SourceError"); - if (result.failure.cause._tag === "SourceError") { - assert.equal( - result.failure.cause.message, - "Failed to derive Clerk Frontend API URL from the publishable key.", - ); - assert.instanceOf(result.failure.cause.cause, Error); - } - } - }), -); - -it("resolves relay client tracing from runtime config with build-time fallback", () => { - const fallback = { - tracesUrl: "https://embedded.example.test/v1/traces", - tracesDataset: "embedded-dataset", - tracesToken: "embedded-token", - }; - - assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback); - assert.deepEqual( - resolveRelayClientTracingConfig( - { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", - }, - fallback, - ), - { - tracesUrl: "https://runtime.example.test/v1/traces", - tracesDataset: "runtime-dataset", - tracesToken: "runtime-token", - }, - ); - assert.equal( - resolveRelayClientTracingConfig( - { - T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces", - T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset", - T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token", - }, - fallback, - ), - null, - ); -}); - it("product opt-out disables relay tracing with either built-in or runtime credentials", () => { const fallback = { tracesUrl: "https://embedded.example.test/v1/traces", diff --git a/apps/server/src/cloud/relayResponse.test.ts b/apps/server/src/cloud/relayResponse.test.ts index 88170942ee17..73ff17fcc619 100644 --- a/apps/server/src/cloud/relayResponse.test.ts +++ b/apps/server/src/cloud/relayResponse.test.ts @@ -41,42 +41,6 @@ it("reports a transport failure category without exposing request or cause detai expect(shouldRetryRelayRequest(error)).toBe(true); }); -it.effect("reports the tunnel limit and relay trace instead of a generic 403", () => - Effect.gen(function* () { - const error = yield* filterRelayResponse( - response(403, { - _tag: "RelayEnvironmentLinkLimitExceededError", - code: "environment_link_limit_exceeded", - maxTunnels: 3, - traceId: "trace-limit", - }), - ).pipe(Effect.mapError(relayRequestError), Effect.flip); - - expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "forbidden" }); - expect(error.message).toContain("at most 3 tunnels"); - expect(error.message).toContain("Unlink an unused environment"); - expect(error.message).toContain("Trace ID: trace-limit"); - }), -); - -it.effect("makes revoked authorization actionable and non-retryable", () => - Effect.gen(function* () { - const error = yield* filterRelayResponse( - response(401, { - _tag: "RelayAuthInvalidError", - code: "auth_invalid", - reason: "invalid_bearer", - traceId: "trace-auth", - }), - ).pipe(Effect.mapError(relayRequestError), Effect.flip); - - expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" }); - expect(error.message).toContain("invalid_bearer"); - expect(error.message).toContain("t3 connect login"); - expect(error.message).toContain("Trace ID: trace-auth"); - }), -); - it.effect("retries an expired link proof but not a revoked bearer, both 401", () => Effect.gen(function* () { const expired = yield* filterRelayResponse( @@ -143,16 +107,6 @@ it.effect("keeps the relay's Retry-After on a retryable failure", () => }), ); -it.effect.each([408, 429, 500, 502, 503, 504])( - "keeps transient HTTP %s failures retryable", - (status) => - Effect.gen(function* () { - const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" }); - expect(error.message).toContain(`HTTP ${status}`); - }), -); - it.effect.each([ { status: 401, attempts: 1 }, { status: 403, attempts: 1 }, @@ -173,27 +127,3 @@ it.effect.each([ expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success"); }), ); - -it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", () => - Effect.gen(function* () { - const error = yield* filterRelayResponse( - response(500, { - _tag: "RelayInternalError", - code: "internal_error", - reason: "upstream_unavailable", - traceId: "trace-cleanup", - }), - ).pipe(Effect.flip); - - expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unavailable" }); - expect(error.message).toContain("upstream_unavailable"); - expect(error.message).toContain("Trace ID: trace-cleanup"); - }), -); - -it.effect("leaves successful response bodies available to their decoder", () => - Effect.gen(function* () { - const result = yield* filterRelayResponse(response(200, '{"ok":true}')); - expect(yield* result.json).toEqual({ ok: true }); - }), -); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 61f069e76ed9..92cafe128134 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -290,29 +290,6 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); - it.effect("reports a failed continuation-marker cleanup", () => - Effect.gen(function* () { - const updateError = new ServerSelfUpdateError({ reason: "update failed" }); - const clearError = new ServerSelfUpdateError({ reason: "marker cleanup failed" }); - const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ - mode: "web", - selfUpdate: { - update: (_input, reportProgress = () => Effect.void) => - reportProgress("installing").pipe(Effect.andThen(Effect.fail(updateError))), - commitDesktopUpdate: () => Effect.never, - }, - prepare: Effect.succeed([ThreadId.make("thread-cleanup-failure")]), - clear: () => Effect.fail(clearError), - }); - - expect( - yield* selfUpdate - .update({ targetVersion: "1.1.0", continueRunningThreads: true }) - .pipe(Effect.flip), - ).toBe(clearError); - }), - ); - it.effect("keeps continuation markers after the boot-service handoff is accepted", () => Effect.gen(function* () { const events: string[] = []; @@ -346,41 +323,6 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); - it.effect("keeps continuation markers after the desktop handoff is accepted", () => - Effect.gen(function* () { - const events: string[] = []; - const selfUpdate = yield* ServerSelfUpdate.withRunningThreadContinuation({ - mode: "desktop", - selfUpdate: { - update: () => - Effect.succeed({ - targetVersion: "1.2.0", - method: "desktop-app" as const, - desktopUpdateToken: "accepted-desktop-token", - }), - commitDesktopUpdate: (_requestId, onHandoffAccepted = () => Effect.void) => - onHandoffAccepted().pipe(Effect.andThen(Effect.interrupt)), - }, - prepare: Effect.sync(() => { - events.push("prepare"); - return [ThreadId.make("thread-accepted-desktop-handoff")]; - }), - clear: () => Effect.sync(() => void events.push("clear")), - }); - - yield* selfUpdate.update({ - targetVersion: "1.2.0", - continueRunningThreads: true, - }); - const exit = yield* selfUpdate - .commitDesktopUpdate("accepted-desktop-token") - .pipe(Effect.exit); - - expect(exit._tag).toBe("Failure"); - expect(events).toEqual(["prepare"]); - }), - ); - it.effect("clears continuation markers for mixed failure and interrupt causes", () => Effect.gen(function* () { const events: string[] = []; @@ -424,18 +366,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); - it.effect("stages and preflights before asking the launcher for an update ID", () => - Effect.gen(function* () { - const { selfUpdate, order } = yield* makeHarness(); - expect(yield* selfUpdate.update({ targetVersion: "1.1.0" })).toEqual({ - targetVersion: "1.1.0", - method: "boot-service", - updateId: "launcher-id", - }); - expect(order).toEqual(["download", "extract", "preflight", "accept"]); - }), - ); - + // regression: #5181 make remote updates rollback-safe it.effect("rejects invalid versions and desktop-managed servers before staging", () => Effect.gen(function* () { const web = yield* makeHarness(); @@ -474,15 +405,6 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); - it.effect("preserves the preflight refusal reason", () => - Effect.gen(function* () { - const { selfUpdate } = yield* makeHarness({ preflight: "blocked" }); - expect((yield* selfUpdate.update({ targetVersion: "1.1.0" }).pipe(Effect.flip)).reason).toBe( - "local update required", - ); - }), - ); - it.effect("allows only one update at a time", () => Effect.gen(function* () { const requested = yield* Deferred.make(); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index b187e97055e2..5f928684db49 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -25,6 +25,7 @@ import { HttpClient } from "effect/http"; import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; +import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -33,6 +34,7 @@ import { pinnedRuntimeCommand, PinnedRuntimeInstallError, PinnedRuntimePreflightBlockedError, + prunePinnedRuntimes, } from "./pinnedRuntime.ts"; import { decodeServicePreflightResult } from "./servicePreflight.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; @@ -204,6 +206,19 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ); const inFlight = yield* Ref.make(false); + // A managed server starting means its launcher has settled on a version, so runtimes left by + // earlier updates can go. + if (launcher.managed) { + yield* prunePinnedRuntimes({ baseDir: serverConfig.baseDir, keep: [packageJson.version] }).pipe( + Effect.tap((removed) => + removed.length === 0 + ? Effect.void + : Effect.logInfo("Removed unused t3 runtimes.", { versions: removed }), + ), + Effect.forkScoped, + ); + } + const capability: ServerSelfUpdateCapability | null = serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null; const failWith = (reason: string, cause?: unknown) => diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 1b9cf8e46c41..9a65c5aad93f 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,7 +1,6 @@ import { expect, it } from "@effect/vitest"; import { runServicePreflight } from "./servicePreflight.ts"; -import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; it.each([1, 2])("blocks legacy launcher protocol %i", (launcherProtocol) => { expect( @@ -17,17 +16,3 @@ it.each([1, 2])("blocks legacy launcher protocol %i", (launcherProtocol) => { "This release requires a newer T3 Code service launcher. Update it on the server machine.", }); }); - -it("accepts the current launcher protocol", () => { - expect( - runServicePreflight({ - databasePath: "/missing/state.sqlite", - launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - version: "1.2.3", - }), - ).toEqual({ - status: "ready", - version: "1.2.3", - launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, - }); -}); diff --git a/apps/server/src/cloud/traceRelayRequest.test.ts b/apps/server/src/cloud/traceRelayRequest.test.ts index e00e34cc2189..6bd9ccc9c023 100644 --- a/apps/server/src/cloud/traceRelayRequest.test.ts +++ b/apps/server/src/cloud/traceRelayRequest.test.ts @@ -43,45 +43,6 @@ describe("relay request tracing", () => { expect(Option.isNone(span.parent)).toBe(true); }), ); - - it.effect("continues an authenticated relay trace with the product tracer", () => - Effect.gen(function* () { - const spans: Array = []; - const productTracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const request = HttpServerRequest.fromWeb( - new Request("https://environment.example.test/api/t3-cloud/mint-credential", { - headers: { - traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01", - }, - }), - ); - - yield* traceAuthenticatedRelayRequest( - Effect.void.pipe(Effect.withSpan("relay.mint.handler")), - AUTHENTICATION, - ).pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, request), - Effect.provideService(RelayClientTracer, Option.some(productTracer)), - ); - - expect(spans.map((span) => span.name)).toEqual([ - "environment.relay.request", - "EnvironmentAuth.authenticateHttpRequest", - "relay.mint.handler", - ]); - const [relaySpan, authentication, handler] = spans; - expect(Option.getOrUndefined(authentication!.parent)?.spanId).toBe(relaySpan!.spanId); - expect(relaySpan!.traceId).toBe("0123456789abcdef0123456789abcdef"); - expect(Option.getOrUndefined(relaySpan!.parent)?.spanId).toBe("0123456789abcdef"); - expect(Option.getOrUndefined(handler!.parent)?.spanId).toBe(relaySpan!.spanId); - }), - ); }); describe("relay request tracing boundary", () => { diff --git a/apps/server/src/codexModelOptions.test.ts b/apps/server/src/codexModelOptions.test.ts index d035a60a46de..4a720d087b9c 100644 --- a/apps/server/src/codexModelOptions.test.ts +++ b/apps/server/src/codexModelOptions.test.ts @@ -5,14 +5,6 @@ import { createModelSelection } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "./codexModelOptions.ts"; -it("returns the selected Codex service tier id", () => { - const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5", [ - { id: "serviceTier", value: "flex" }, - ]); - - assert.equal(getCodexServiceTierOptionValue(selection), "flex"); -}); - it("keeps legacy persisted fast mode selections working", () => { const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ { id: "fastMode", value: true }, diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts deleted file mode 100644 index 17827bb38ba9..000000000000 --- a/apps/server/src/compileCache.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - the test kills a real Node process to prove the cache is on disk. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { assert, it } from "@effect/vitest"; - -it.each([false, true])( - "persists an enabled cache before forced exit (disabled: %s)", - async (disabled) => { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-compile-cache-")); - try { - const cacheDirectory = NodePath.join(directory, "cache"); - const child = NodeChildProcess.spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - `import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; -const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); -await Effect.runPromise(flushCompileCache); -process.kill(process.pid, "SIGKILL");`, - ], - { - encoding: "utf8", - env: { - ...process.env, - NODE_COMPILE_CACHE: cacheDirectory, - NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, - }, - }, - ); - assert.equal(child.error, undefined); - assert.equal(child.stderr, ""); - assert.notEqual(child.status, 0); - const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true }).catch( - (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return []; - throw error; - }, - ); - const files = await Promise.all( - entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), - ); - assert.equal( - files.some((entry) => entry.isFile()), - !disabled, - ); - } finally { - await NodeFSP.rm(directory, { recursive: true, force: true }); - } - }, -); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts deleted file mode 100644 index d21a96c9f415..000000000000 --- a/apps/server/src/config.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import { assert, describe, it } from "@effect/vitest"; - -import * as ServerConfig from "./config.ts"; - -describe("otlpResource", () => { - const resource = (resourceAttributes: Record) => - ServerConfig.otlpResource( - { mode: "web", otelEnvironment: { ...OtelEnvironment.none, resourceAttributes } }, - "box-1", - ); - - it("names the host, unless OTEL_RESOURCE_ATTRIBUTES already does", () => { - assert.equal(resource({}).attributes["host.name"], "box-1"); - assert.equal(resource({ "host.name": "from-env" }).attributes["host.name"], "from-env"); - assert.equal(resource({ "host.name": "" }).attributes["host.name"], "box-1"); - }); -}); diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts index f9e2cce70ecf..86d445d1a52c 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts @@ -105,20 +105,6 @@ const makeHarness = Effect.fn("test.make_desktop_app_update_harness")(function* }); it.layer(NodeServices.layer)("desktop app update", (it) => { - it.effect("is unavailable without desktop mode or the control fd", () => - Effect.gen(function* () { - const web = yield* makeHarness({ mode: "web" }); - expect(web.service.available).toBe(false); - const noFd = yield* makeHarness({ controlFd: undefined }); - expect(noFd.service.available).toBe(false); - expect((yield* noFd.service.run(() => Effect.void).pipe(Effect.flip)).reason).toContain( - "not started by the T3 Code desktop app", - ); - const desktop = yield* makeHarness(); - expect(desktop.service.available).toBe(true); - }), - ); - it.effect("collapses state reports into progress stages and succeeds on installing", () => Effect.gen(function* () { const { service } = yield* makeHarness({ @@ -146,31 +132,6 @@ it.layer(NodeServices.layer)("desktop app update", (it) => { }), ); - it.effect("maps up-to-date and failed outcomes to readable errors", () => - Effect.gen(function* () { - const upToDate = yield* makeHarness({ - reports: (requestId) => [ - report(requestId, makeState({ status: "up-to-date" }), { outcome: "up-to-date" }), - ], - }); - expect((yield* upToDate.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( - "The T3 Code desktop app on this machine is already up to date on 1.2.3.", - ); - - const failed = yield* makeHarness({ - reports: (requestId) => [ - report(requestId, makeState({ status: "error", message: "feed unreachable" }), { - outcome: "failed", - reason: "feed unreachable", - }), - ], - }); - expect((yield* failed.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( - "feed unreachable", - ); - }), - ); - it.effect("replays a retained commit failure for the preparation token", () => Effect.gen(function* () { const { service } = yield* makeHarness({ diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 2d05f8343651..41e1de430469 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import type { DeviceActionInput } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts"; +import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import type { DeviceHostReady } from "./DeviceHost.ts"; type Call = { command: string; args: ReadonlyArray; stdin?: string }; @@ -43,100 +42,7 @@ const makeReady = ( const udid = "SIM-1"; -describe("supportsAction", () => { - it("advertises platform-specific toggles", () => { - const toggle = (setting: Extract["setting"]) => - ({ type: "setToggle", deviceId: udid, setting, value: true }) as const; - expect(supportsAction("ios", toggle("voiceOver"))).toBe(true); - expect(supportsAction("android", toggle("voiceOver"))).toBe(false); - expect(supportsAction("android", toggle("networkEnabled"))).toBe(true); - expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false); - expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe( - true, - ); - expect( - supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }), - ).toBe(false); - expect( - supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }), - ).toBe(true); - expect( - supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }), - ).toBe(false); - }); -}); - describe("runDeviceAction", () => { - it.effect("maps shared text sizes onto simctl content-size categories", () => - Effect.gen(function* () { - const { ready, calls } = makeReady(); - yield* runDeviceAction(ready, "ios", { - type: "setTextSize", - deviceId: udid, - value: "extra-large", - }); - expect(calls).toEqual([ - { command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] }, - ]); - }), - ); - - it.effect("maps shared text sizes onto Android font_scale", () => - Effect.gen(function* () { - const { ready, calls } = makeReady(); - yield* runDeviceAction(ready, "android", { - type: "setTextSize", - deviceId: "emulator-5554", - value: "large", - }); - expect(calls[0]?.args).toEqual([ - "-s", - "emulator-5554", - "shell", - "settings", - "put", - "system", - "font_scale", - "1.15", - ]); - }), - ); - - it.effect( - "rotates emulators through the accelerometer and physical devices through the lock", - () => - Effect.gen(function* () { - const emulator = makeReady(); - yield* runDeviceAction(emulator.ready, "android", { - type: "setOrientation", - deviceId: "emulator-5554", - value: "landscape_left", - }); - expect(emulator.calls.at(-1)?.args).toEqual([ - "-s", - "emulator-5554", - "emu", - "sensor", - "set", - "acceleration", - "9.81:0:0", - ]); - // A device that refuses `wm size` keeps the portrait-native mapping. - const phone = makeReady((call) => - call.args.join(" ").endsWith("shell wm size") ? { code: 1 } : {}, - ); - yield* runDeviceAction(phone.ready, "android", { - type: "setOrientation", - deviceId: "R5CT1234", - value: "landscape_right", - }); - expect(phone.calls.at(-1)).toEqual({ - command: "adb", - args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"], - }); - }), - ); - it.effect.each([ ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"], ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"], @@ -154,92 +60,6 @@ describe("runDeviceAction", () => { expect(calls.at(-1)?.args.at(-1)).toBe(expected); }), ); - - it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () => - Effect.gen(function* () { - const { ready, calls } = makeReady(); - yield* runDeviceAction(ready, "ios", { - type: "setToggle", - deviceId: udid, - setting: "voiceOver", - value: true, - }); - expect(calls).toEqual([ - { - command: "xcrun", - args: [ - "simctl", - "spawn", - udid, - "/hub/simax/serve-sim-ax-settings", - "set", - "voiceover", - "on", - ], - }, - ]); - }), - ); - - it.effect("fails clearly when the helper is missing", () => - Effect.gen(function* () { - const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null }); - const error = yield* Effect.flip( - runDeviceAction(ready, "ios", { - type: "setColorFilter", - deviceId: udid, - value: "grayscale", - }), - ); - expect(error._tag).toBe("DeviceActionUnavailableError"); - expect(error.message).toContain("requires a helper"); - }), - ); - - it.effect("rejects actions the platform does not support without running anything", () => - Effect.gen(function* () { - const { ready, calls } = makeReady(); - const error = yield* Effect.flip( - runDeviceAction(ready, "android", { - type: "sendPush", - deviceId: "emulator-5554", - appId: "com.example", - payload: "hi", - }), - ); - expect(error.message).toContain("not supported on android"); - expect(calls).toEqual([]); - }), - ); - - it.effect("surfaces non-zero exit codes as operation errors", () => - Effect.gen(function* () { - const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" })); - const error = yield* Effect.flip( - runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }), - ); - expect(error.operation).toBe("appearance"); - expect(error.message).toContain("exit code 1"); - expect(error.message).not.toContain("Invalid device: SIM-1"); - expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ - stderr: "Invalid device: SIM-1", - }); - }), - ); - - it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () => - Effect.gen(function* () { - const { ready, calls } = makeReady(); - yield* runDeviceAction(ready, "ios", { - type: "sendPush", - deviceId: udid, - appId: "com.example.app", - payload: "Hello", - }); - expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]); - expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}'); - }), - ); }); describe("readDeviceDetail", () => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 5d2ddca0f740..4ba532d7b256 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -67,7 +67,7 @@ const IOS_TOGGLES = new Set([ ]); const ANDROID_TOGGLES = new Set(["reduceMotion", "networkEnabled"]); -export const supportsAction = (platform: DevicePlatform, input: DeviceActionInput): boolean => { +const supportsAction = (platform: DevicePlatform, input: DeviceActionInput): boolean => { const actions = platform === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS; if (!actions.has(input.type)) return false; if (input.type === "setToggle") { diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index f59952a0aa94..07b45737f673 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -87,13 +87,6 @@ describe("device hub proxy", () => { expect(finalized()).toBe(1); }); - it("releases resources when upstream acquisition fails", async () => { - const { handler, finalized } = fixture([AuthOrchestrationReadScope], true); - const response = await handler(new Request("http://t3.test/api/device-hub/api/devices")); - expect(response.status).toBe(500); - expect(finalized()).toBe(1); - }); - it.each(["/vendor/serve-sim/helper/ws", "/vendor/serve-emu/ws"])( "rejects input socket %s for a read-only session", async (path) => { @@ -238,28 +231,11 @@ it.each([ expect(requests).toEqual([]); }); -it.each([1, 3])( - "forwards fixed Duo display %s through the authenticated read proxy", - async (panel) => { - const { handler, requests } = fixture([AuthOrchestrationReadScope]); - const route = `/vendor/serve-sim/helper/duo/panel/${panel}/stream.avcc`; - const response = await handler( - new Request(`http://t3.test/api/device-hub${route}?wsTicket=secret`), - ); - expect(response.status).toBe(200); - await response.text(); - expect(requests).toEqual([`http://hub.test${route}`]); - }, -); - -it.each(["/panel/2/stream.avcc", "/panel/1/webrtc/offer", "/panel/3/exec"])( - "rejects unsupported Duo route %s", - async (route) => { - const { handler, requests } = fixture([AuthOrchestrationReadScope]); - const response = await handler( - new Request(`http://t3.test/api/device-hub/vendor/serve-sim/helper/duo${route}`), - ); - expect(response.status).toBe(404); - expect(requests).toEqual([]); - }, -); +it("rejects unsupported Duo routes", async () => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const response = await handler( + new Request("http://t3.test/api/device-hub/vendor/serve-sim/helper/duo/panel/1/webrtc/offer"), + ); + expect(response.status).toBe(404); + expect(requests).toEqual([]); +}); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f7e578e4d1a4..53eaa6bb26c9 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -7,21 +7,18 @@ import { LOCAL_DEVICE_HOST_ID, ThreadId, type DeviceSummary, - type DeviceServiceState, type ServerSettings as Settings, type SshDeviceHostConfig, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; -import { SshCommandError } from "@t3tools/ssh/errors"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -42,43 +39,6 @@ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError)); -const baseState: DeviceServiceState = { - hosts: [], - hostStatus: "idle", - hostStatuses: {}, - devices: [], - sessions: [], - onboardingCompleted: false, - agentAccessEnabled: false, - hubBasePath: "/api/device-hub", - revision: 0, -}; - -describe("DeviceService.stateStream", () => { - it.effect("emits the current snapshot and then every published change", () => - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded(); - const current = yield* Ref.make(baseState); - const service: Pick = { - state: Ref.get(current), - subscribe: PubSub.subscribe(pubsub), - }; - - const collected = yield* DeviceService.stateStream( - service as DeviceService.DeviceService["Service"], - ).pipe(Stream.take(3), Stream.runCollect, Effect.forkChild); - yield* Effect.yieldNow; - for (const revision of [1, 2]) { - const next = { ...baseState, revision, hostStatus: "ready" as const }; - yield* Ref.set(current, next); - yield* PubSub.publish(pubsub, next); - } - const seen = yield* Fiber.join(collected); - expect(seen.map((state) => state.revision)).toEqual([0, 1, 2]); - }), - ); -}); - const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> }, @@ -305,43 +265,6 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); - it.effect( - "preserves missing-runtime guidance and causes through manual and agent readiness", - () => - Effect.gen(function* () { - const underlying = new Error("private lookup diagnostics"); - const runtimeFailure = new NodeRuntimeUnavailableError({ - feature: "Local device support", - cause: underlying, - }); - const { service, settings, requests } = yield* fixture( - Effect.void, - undefined, - false, - runtimeFailure, - ); - yield* Ref.update(settings, (current) => ({ - ...current, - enableDeviceSupport: true, - enableAgentDeviceAccess: true, - })); - for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) { - const error = yield* readiness.pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "DeviceHostUnavailableError", - reason: expect.stringContaining("Install Node.js"), - cause: runtimeFailure, - }); - expect(error.message).not.toContain(underlying.message); - } - expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({ - status: "failed", - detail: expect.stringContaining("Install Node.js"), - }); - expect(requests).toEqual([]); - }).pipe(Effect.scoped), - ); - it.effect("listing and provider startup do not start helpers before consent", () => Effect.gen(function* () { const { service, starts, requests } = yield* fixture(); @@ -566,40 +489,6 @@ describe("device discovery after server restart", () => { const missingPlistMessage = "[apple-utils] Failed to read CoreSimulator plist at /Users/test/Library/Developer/CoreSimulator/Devices/deleted-simulator/device.plist:"; -it.effect.each([ - { - message: missingPlistMessage, - error: "Error: ENOENT: no such file or directory, open 'device.plist'", - ignored: true, - }, - { - message: missingPlistMessage, - error: "Error: EACCES: permission denied, open 'device.plist'", - ignored: false, - }, - { message: missingPlistMessage, error: undefined, ignored: false }, - { - message: "[apple-utils] Failed to parse CoreSimulator device.plist:", - error: "Invalid XML", - ignored: false, - }, - { - message: "[apple-utils] Failed to list devices:", - error: "Error: ENOENT: simctl missing", - ignored: false, - }, -])("handles discovery diagnostic: $error", ({ message, error, ignored }) => - Effect.gen(function* () { - const { service, discoveryErrors } = yield* fixture(); - yield* Ref.set(discoveryErrors, [{ message, ...(error ? { error } : {}) }]); - const state = yield* service.configure({ enabled: true }); - expect(state.hostStatus).toBe("ready"); - expect(state.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); - expect(state.hostStatusDetail).toBe(ignored ? undefined : message); - expect(state.hostStatuses.local?.detail).toBe(ignored ? undefined : message); - }).pipe(Effect.scoped), -); - it.effect("keeps other discovery warnings and clears them when only a deleted plist remains", () => Effect.gen(function* () { const { service, discoveryErrors } = yield* fixture(); @@ -652,6 +541,7 @@ it.effect.each([ }).pipe(Effect.scoped), ); +// regression: #22012 report Android emulator boot failures it.effect("includes nested hub boot diagnostics and bounds them to 2,000 characters", () => Effect.gen(function* () { const diagnostic = "Error: emulator binary missing\n" + "diagnostic output\n".repeat(200); @@ -997,133 +887,6 @@ it.effect("retry keeps device and agent consent unchanged", () => }).pipe(Effect.scoped), ); -it.effect("publishes update detail for the correct host", () => - Effect.gen(function* () { - const { service } = yield* fixture(); - const changes = yield* service.subscribe; - yield* service.configure({ enabled: true }); - const states = yield* PubSub.takeAll(changes); - expect( - states.some( - (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", - ), - ).toBe(true); - }).pipe(Effect.scoped), -); - -it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => - Effect.gen(function* () { - const { service, settings } = yield* fixture( - Effect.void, - undefined, - false, - new DeviceHost.DeviceHostError({ - hostId: LOCAL_DEVICE_HOST_ID, - step: "probe", - cause: "private diagnostics", - }), - ); - yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); - const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); - expect(state.supportsHostRetry).toBe(true); - expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "failed", - detail: "Could not connect to this host over SSH.", - }); - }).pipe(Effect.scoped), -); - -it.effect.each([ - { - name: "missing Node among private diagnostic lines", - exitCode: 1, - stderr: - "private diagnostics\r\nNode is missing from the non-interactive SSH PATH\r\nprivate diagnostics", - expected: - "Connected over SSH, but Node is not on this host's PATH for non-interactive SSH commands.", - }, - { - name: "another remote command failure", - exitCode: 2, - stderr: "private diagnostics", - expected: "Connected over SSH, but the device host check failed (exit code 2).", - }, - { - name: "an unrecognized exit-one diagnostic", - exitCode: 1, - stderr: "private diagnostics: Node is missing from the non-interactive SSH PATH", - expected: "Connected over SSH, but the device host check failed (exit code 1).", - }, - { - name: "a connection failure", - exitCode: 255, - stderr: "Node is missing from the non-interactive SSH PATH", - expected: "Could not connect to this host over SSH.", - }, - { - name: "a command that could not start", - exitCode: null, - stderr: "private diagnostics", - expected: "Could not connect to this host over SSH.", - }, - { - name: "an untyped offline cause", - exitCode: undefined, - stderr: "private diagnostics", - expected: "Could not connect to this host over SSH.", - }, -])("reports $name through manual and agent host readiness", ({ exitCode, stderr, expected }) => - Effect.gen(function* () { - const failure = new DeviceHost.DeviceHostError({ - hostId: LOCAL_DEVICE_HOST_ID, - step: "probe", - cause: - exitCode === undefined - ? "offline" - : new SshCommandError({ - command: ["ssh", "private arguments"], - exitCode, - stderr, - stdout: "private output", - message: "private diagnostics", - }), - }); - const { service, settings } = yield* fixture(Effect.void, undefined, false, failure); - yield* Ref.update(settings, (current) => ({ - ...current, - enableDeviceSupport: true, - enableAgentDeviceAccess: true, - })); - for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) { - const error = yield* readiness.pipe(Effect.flip); - expect(error).toMatchObject({ reason: expected, cause: failure }); - expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "failed", - detail: expected, - }); - } - for (const state of [yield* service.list, yield* service.retryHost(LOCAL_DEVICE_HOST_ID)]) { - expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ - status: "failed", - detail: expected, - }); - } - }).pipe(Effect.scoped), -); - -it.effect("version discovery does not grant consent or start device tools", () => - Effect.gen(function* () { - const { service, starts, agentStarts, requests } = yield* fixture(); - const state = yield* service.inspect; - expect(state.supportsToolInspection).toBe(true); - expect(state.hostStatus).toBe("disabled"); - expect(state.hosts).toHaveLength(1); - expect(starts).toEqual([]); - expect(agentStarts).toEqual([]); - expect(requests).toEqual([]); - }).pipe(Effect.scoped), -); - it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => Effect.gen(function* () { const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 633338b584ee..31b0ed0b1291 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -312,180 +312,6 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("stops only the sessions a previous server recorded, found by name", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([ - { name: "T3 Code · EAS Simulator · left", appId: APP_ID, recordedAt: 0 }, - // Recorded long enough ago that Expo has ended it, so the record goes. - { name: "T3 Code · EAS Simulator · gone", appId: APP_ID, recordedAt: -181 * 60_000 }, - // Recorded just now and not listed yet: kept for a later sweep. - { name: "T3 Code · EAS Simulator · new", appId: APP_ID, recordedAt: 0 }, - ]), - ); - const expo = fakeExpo({ - active: [ - { id: "left-behind", status: "IN_PROGRESS", name: "T3 Code · EAS Simulator · left" }, - ], - }); - const host = yield* makeHost(expo, config(), yield* Scope.make()); - yield* host.ensureReady(() => Effect.void); - // The sweep runs in the background, so readiness does not wait on Expo. - const ledgerNames = Effect.map( - Effect.flatMap(fs.readFileString(ledger), (text) => - Effect.try(() => JSON.parse(text) as ReadonlyArray<{ name: string }>), - ), - (entries) => entries.map((entry) => entry.name), - ); - yield* Effect.eventually( - Effect.flatMap(ledgerNames, (names) => - names.length === 1 ? Effect.void : Effect.fail("not swept yet"), - ), - ); - expect(expo.stopped()).toEqual(["left-behind"]); - expect(yield* ledgerNames).toEqual(["T3 Code · EAS Simulator · new"]); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("counts a fresh record a previous server left until Expo lists it or would have", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([{ name: "T3 Code · EAS Simulator · new", appId: APP_ID, recordedAt: 0 }]), - ); - const expo = fakeExpo(); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - // The record is fresh and Expo does not list it yet: it holds the slot. - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - yield* TestClock.adjust("6 minutes"); - yield* ready.devices.list; - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("counts an old record a previous server left until a sweep stops its session", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([ - { name: "T3 Code · EAS Simulator · old", appId: APP_ID, recordedAt: -10 * 60_000 }, - ]), - ); - const expo = fakeExpo({ - active: [ - { id: "old-session", status: "IN_PROGRESS", name: "T3 Code · EAS Simulator · old" }, - ], - }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - // Listing sweeps before anything starts, so the old session is stopped first. - const [catalogDevice] = (yield* ready.devices.list).devices; - expect(expo.stopped()).toContain("old-session"); - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("keeps an old record counted while the listing that would find it fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([ - { name: "T3 Code · EAS Simulator · old", appId: APP_ID, recordedAt: -10 * 60_000 }, - ]), - ); - let listFails = true; - const expo = fakeExpo({ - active: [ - { id: "old-session", status: "IN_PROGRESS", name: "T3 Code · EAS Simulator · old" }, - ], - failList: () => listFails, - }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - // The sweep could not look, so the record still holds the slot. - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - listFails = false; - yield* ready.devices.list; - expect(expo.stopped()).toContain("old-session"); - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect( - "frees the slot of a record Expo has ended by now, but keeps it until a listing confirms", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([ - { name: "T3 Code · EAS Simulator · gone", appId: APP_ID, recordedAt: -181 * 60_000 }, - { - name: "T3 Code · EAS Simulator · refused", - appId: APP_ID, - recordedAt: -181 * 60_000, - refused: true, - }, - // No queue holds a session for a day: forgotten unconfirmed. - { - name: "T3 Code · EAS Simulator · ancient", - appId: APP_ID, - recordedAt: -25 * 60 * 60_000, - }, - ]), - ); - // The app is gone, say: listing it fails for good. - const expo = fakeExpo({ failList: () => true }); - const host = yield* makeHost( - expo, - config({ maxConcurrentSessions: 1 }), - yield* Scope.make(), - ); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - const names = (JSON.parse(yield* fs.readFileString(ledger)) as Array<{ name: string }>).map( - (entry) => entry.name, - ); - // The listing never confirmed the two: a queued session could still be - // running, so their records stay for the reaper. - expect(names.filter((name) => !/[0-9a-f]{6}$/.test(name))).toEqual([ - "T3 Code · EAS Simulator · gone", - "T3 Code · EAS Simulator · refused", - ]); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - it.effect( "keeps a refused create's record for the reaper without counting it after a restart", () => @@ -519,30 +345,6 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("leaves a replacement's session alone once its own scope has closed", () => - Effect.gen(function* () { - // One Expo for both instances: they share the record and the account. - const expo = fakeExpo(); - const first = yield* Scope.make(); - const old = yield* makeHost(expo, config(), first); - const oldReady = yield* old.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* oldReady.devices.list).devices; - expect(yield* oldReady.devices.boot(catalogDevice!)).toBe("session-1"); - // Settings saved: the old instance goes, a replacement takes its id. - yield* Scope.close(first, Exit.void); - expect(expo.stopped()).toEqual(["session-1"]); - const replacement = yield* makeHost(expo, config(), yield* Scope.make()); - const ready = yield* replacement.ensureReady(() => Effect.void); - const [device] = (yield* ready.devices.list).devices; - expect(yield* ready.devices.boot(device!)).toBe("session-2"); - // A listing still running on the old instance must not sweep the new - // session, which it does not own but its replacement does. - yield* TestClock.adjust("2 minutes"); - yield* oldReady.devices.list; - expect(expo.stopped()).toEqual(["session-1"]); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - it.effect( "does not sweep a replacement's session from a listing in flight when its scope closes", () => @@ -572,102 +374,6 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("waits for a record write in flight before its scope closes", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const gate = yield* makeGate; - let armed = false; - // The ledger read inside a write waits on the gate once armed. - const gatedFs: FileSystem.FileSystem = { - ...fs, - readFileString: (path, encoding) => - Effect.gen(function* () { - if (armed && path.endsWith("eas.json")) { - armed = false; - yield* Deferred.succeed(gate.requested, undefined); - yield* Deferred.await(gate.release); - } - return yield* fs.readFileString(path, encoding); - }), - }; - let status = "IN_PROGRESS"; - // Nothing is listed, so the close's own sweep writes nothing: only the - // write in flight can hold the close. - const expo = fakeExpo({ status: () => status, omitCreatedFromActive: true }); - const first = yield* Scope.make(); - const old = yield* makeHost(expo, config(), first).pipe( - Effect.provideService(FileSystem.FileSystem, gatedFs), - ); - const oldReady = yield* old.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* oldReady.devices.list).devices; - expect(yield* oldReady.devices.boot(catalogDevice!)).toBe("session-1"); - // Expo ended the session; the next listing forgets its record. - status = "STOPPED"; - yield* TestClock.adjust("20 seconds"); - armed = true; - const listing = yield* oldReady.devices.list.pipe(Effect.forkChild); - yield* Deferred.await(gate.requested); - // Settings saved while that write is in flight: closing waits for it. - const listingsBefore = expo.named("T3ActiveEasSimulatorSessions").length; - const closing = yield* Scope.close(first, Exit.void).pipe(Effect.forkChild); - // The close reads the record and lists sessions in real time: yield until - // its sweep has listed, then a good deal more, before looking at it. - while (expo.named("T3ActiveEasSimulatorSessions").length === listingsBefore) { - yield* Effect.yieldNow; - } - for (let i = 0; i < 200; i++) yield* Effect.yieldNow; - expect(closing.pollUnsafe()).toBeUndefined(); - yield* Deferred.succeed(gate.release, undefined); - yield* Fiber.join(listing); - yield* Fiber.join(closing); - const replacement = yield* makeHost(expo, config(), yield* Scope.make()); - const ready = yield* replacement.ensureReady(() => Effect.void); - const [device] = (yield* ready.devices.list).devices; - status = "IN_PROGRESS"; - expect(yield* ready.devices.boot(device!)).toBe("session-2"); - // The old write landed first, so the replacement's record is intact. - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const entries = JSON.parse( - yield* fs.readFileString(path.join(server.stateDir, "device", "eas", "eas.json")), - ) as Array<{ name: string }>; - expect(entries).toHaveLength(1); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("keeps an old record whose session is listed but cannot be stopped", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - yield* fs.makeDirectory(path.dirname(ledger), { recursive: true }); - yield* fs.writeFileString( - ledger, - JSON.stringify([ - { name: "T3 Code · EAS Simulator · old", appId: APP_ID, recordedAt: -181 * 60_000 }, - ]), - ); - // Expo still lists it (queued for hours, say) and refuses to stop it. - const expo = fakeExpo({ - active: [ - { id: "old-session", status: "IN_PROGRESS", name: "T3 Code · EAS Simulator · old" }, - ], - failStop: () => true, - }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - // Found and still running: its slot stays taken and its record stays. - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - const names = (JSON.parse(yield* fs.readFileString(ledger)) as Array<{ name: string }>).map( - (entry) => entry.name, - ); - expect(names).toContain("T3 Code · EAS Simulator · old"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - it.effect("stops the session a refused create made anyway", () => Effect.gen(function* () { // Expo refuses the create but lists a session by its name. @@ -700,33 +406,6 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("keeps a session tracked when the open that started it is interrupted", () => - Effect.gen(function* () { - const createGate = yield* makeGate; - const expo = fakeExpo({ createGate }); - const scope = yield* Scope.make(); - const host = yield* makeHost(expo, config(), scope); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - const opening = yield* ready.devices.boot(catalogDevice!).pipe(Effect.forkChild); - yield* Deferred.await(createGate.requested); - yield* Fiber.interrupt(opening); - yield* Deferred.succeed(createGate.release, undefined); - // The start goes on in the host and tracks its session. - yield* Effect.eventually( - ready.devices.list.pipe( - Effect.flatMap(({ devices }) => - devices.some((device) => device.id === "session-1") - ? Effect.void - : Effect.fail("not tracked yet"), - ), - ), - ); - yield* Scope.close(scope, Exit.void); - expect(expo.stopped()).toEqual(["session-1"]); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - it.effect("keeps a session it could not stop counted, listed, and retried", () => Effect.gen(function* () { let stopFails = true; @@ -775,22 +454,6 @@ describe("EAS Simulator host", () => { expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(0); }).pipe(Effect.scoped, Effect.provide(layer)), ); - it.effect("stops listing sessions Expo already ended", () => - Effect.gen(function* () { - let status = "IN_PROGRESS"; - const expo = fakeExpo({ status: () => status }); - const host = yield* makeHost(expo, config(), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - yield* ready.devices.boot(catalogDevice!); - status = "STOPPED"; - yield* TestClock.adjust("20 seconds"); - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); - expect(yield* ready.devices.agentEndpoint!("session-1").pipe(Effect.flip)).toMatchObject({ - reason: "This EAS Simulator device is no longer running.", - }); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); it.effect("stops a session that is still starting when the host stops", () => Effect.gen(function* () { @@ -809,99 +472,4 @@ describe("EAS Simulator host", () => { expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); }).pipe(Effect.scoped, Effect.provide(layer)), ); - - it.effect("leaves a start in progress to its owner when the sweep runs", () => - Effect.gen(function* () { - const previewGate = yield* makeGate; - // Expo lists the starting session by name, as it does while it boots. - const expo = fakeExpo({ previewGate, activeIncludesCreated: true }); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const server = yield* ServerConfig.ServerConfig; - const host = yield* makeHost(expo, config(), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - const booting = yield* ready.devices.boot(catalogDevice!).pipe(Effect.forkChild); - yield* Deferred.await(previewGate.requested); - yield* TestClock.adjust("61 seconds"); - yield* ready.devices.list; - expect(expo.stopped()).toEqual([]); - const ledger = path.join(server.stateDir, "device", "eas", "eas.json"); - expect(JSON.parse(yield* fs.readFileString(ledger))).toHaveLength(1); - yield* Deferred.succeed(previewGate.release, undefined); - expect(yield* Fiber.join(booting)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("keeps a session counted when its start fails and stopping it fails too", () => - Effect.gen(function* () { - let stopFails = true; - const expo = fakeExpo({ failPreview: true, failStop: () => stopFails }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - // The preview probe is retried a few seconds apart before the start gives up. - const failing = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip, Effect.forkChild); - while (failing.pollUnsafe() === undefined) yield* TestClock.adjust("1 second"); - yield* Fiber.join(failing); - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(1); - stopFails = false; - yield* ready.devices.list; - expect(expo.stopped().at(-1)).toBe("session-1"); - expo.allowPreview(); - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("stops only its own session when creating one fails after Expo made it", () => - Effect.gen(function* () { - const expo = fakeExpo({ failCreate: true }); - const host = yield* makeHost(expo, config(), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(expo.stopped()).toEqual(["orphan"]); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("counts a create whose outcome is unknown against the limit, then frees it", () => - Effect.gen(function* () { - // Create fails and the session is not listed yet, so the outcome is unknown. - const expo = fakeExpo({ failCreate: true, omitCreatedFromActive: true }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - // The slot is held: Expo may have made the session. - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - expect(expo.named("T3CreateEasSimulatorSession")).toHaveLength(1); - // After Expo would have listed it, the slot frees and a new start runs. - yield* TestClock.adjust("6 minutes"); - yield* ready.devices.list; - expo.allowCreate(); - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); - - it.effect("holds the slot when a failed create’s session is listed but its stop fails", () => - Effect.gen(function* () { - let stopFails = true; - // Create fails, Expo lists the session by name, and stopping it fails too. - const expo = fakeExpo({ failCreate: true, failStop: () => stopFails }); - const host = yield* makeHost(expo, config({ maxConcurrentSessions: 1 }), yield* Scope.make()); - const ready = yield* host.ensureReady(() => Effect.void); - const [catalogDevice] = (yield* ready.devices.list).devices; - yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - const refused = yield* ready.devices.boot(catalogDevice!).pipe(Effect.flip); - expect(refused.message).toContain("still being cleaned up"); - stopFails = false; - yield* ready.devices.list; - expect(expo.stopped()).toContain("orphan"); - expo.allowCreate(); - expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-1"); - }).pipe(Effect.scoped, Effect.provide(layer)), - ); }); diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 3670717c97aa..bffd912a3987 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -12,7 +12,7 @@ import * as Stream from "effect/Stream"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as NetService from "@t3tools/shared/Net"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -166,14 +166,6 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("returns a command timeout without starting another readiness wait", () => - Effect.gen(function* () { - const { host } = yield* agentFixture(() => Effect.succeed(processOutput(124, "", "", true))); - const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "DeviceHostTimeoutError", timeoutMs: 30_000 }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("uses a registered daemon when reading startup command output fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -207,37 +199,6 @@ describe("AgentDevice startup", () => { expect(attempts).toBe(1); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - - it.effect("allows retry and uses a registered daemon even when discovery exits nonzero", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - let attempts = 0; - const { host } = yield* agentFixture((stateDir) => - Effect.gen(function* () { - attempts++; - if (attempts > 1) { - yield* fs - .writeFileString( - `${stateDir}/daemon.json`, - JSON.stringify({ httpPort: 12345, token: "test-token" }), - ) - .pipe(Effect.orDie); - } - return processOutput(1); - }), - ); - yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip); - const ready = yield* host.ensureAgentReady(() => Effect.void); - expect(ready.agentDevice).toMatchObject({ - baseUrl: "http://127.0.0.1:12345", - token: "test-token", - }); - expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual( - ready.agentDevice, - ); - expect(attempts).toBe(2); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); }); const diagnose = ( @@ -318,22 +279,7 @@ describe("Android SDK availability", () => { }), ); - it.effect("explains that adb alone is insufficient to launch an emulator", () => - Effect.gen(function* () { - const reason = yield* diagnose(["/sdk/platform-tools/adb"], { ANDROID_HOME: "/sdk" }); - expect(reason).toContain("Android Emulator is missing"); - }), - ); - - it.effect("identifies command-line tools required by the device hub", () => - Effect.gen(function* () { - const reason = yield* diagnose(["/sdk/platform-tools/adb", "/sdk/emulator/emulator"], { - ANDROID_HOME: "/sdk", - }); - expect(reason).toContain("Command-line Tools (latest) are missing"); - }), - ); - + // regression: #12017 detect unsupported legacy Android command-line tools it.effect("explains how to upgrade legacy command-line tools in the standard macOS SDK", () => Effect.gen(function* () { const root = "/test/home/Library/Android/sdk"; @@ -349,21 +295,6 @@ describe("Android SDK availability", () => { }), ); - it.effect("recognizes legacy command-line tools on Windows", () => - Effect.gen(function* () { - const reason = yield* diagnose( - [ - "C:\\Android\\Sdk\\platform-tools\\adb.exe", - "C:\\Android\\Sdk\\emulator\\emulator.exe", - "C:\\Android\\Sdk\\tools\\bin\\avdmanager.bat", - ], - { ANDROID_HOME: "C:\\Android\\Sdk" }, - "win32", - ); - expect(reason).toContain("older, unsupported version"); - }), - ); - it.effect("accepts the latest command-line tools when legacy tools are also installed", () => Effect.gen(function* () { const reason = yield* diagnose( @@ -393,45 +324,8 @@ describe("Android SDK availability", () => { expect(reason).toBeNull(); }), ); - - it.effect("reports an absent SDK without running or installing tools", () => - Effect.gen(function* () { - expect(yield* diagnose([], { HOME: "/test/home" })).toContain("Android SDK was not found"); - }), - ); }); -it.effect.each([ - { reason: "timeout", timedOut: true }, - { reason: "signal termination", timedOut: false }, -])("reports AVD enumeration $reason as a failed command", ({ timedOut }) => - Effect.gen(function* () { - const { host } = yield* agentFixture(() => Effect.succeed(processOutput(0)), { - ...processOutput(0), - code: null, - timedOut, - }); - const ready = yield* host.ensureReady(() => Effect.void); - const result = yield* ready.run("emulator", ["-list-avds"]); - expect(result.code).not.toBe(0); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("puts detected Android tools on the helper PATH without losing existing commands", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const environment = LocalDeviceHost.__testing.deviceHostEnvironment( - { PATH: "/usr/bin", HOME: "/test/home" }, - "/sdk", - "darwin", - path, - ); - expect(environment.PATH).toBe("/sdk/platform-tools:/sdk/emulator:/usr/bin"); - expect(environment.ANDROID_HOME).toBe("/sdk"); - expect(environment.HOME).toBe("/test/home"); - }).pipe(Effect.provide(NodePath.layerPosix)), -); - it.effect( "recovers an owned Linux runtime directory without overriding explicit environments", () => @@ -546,47 +440,6 @@ it.effect.each([ }), ); -it.live("limits hub discovery to Android unless the host is a Mac", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const environment = yield* HostProcess.Environment; - const hubArguments = Effect.fn(function* (platform: NodeJS.Platform) { - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-hub-" }); - const installDir = path.join(baseDir, "tools/expo-device-hub", DEVICE_HUB_VERSION); - const entryPath = path.join(installDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); - const argumentsPath = path.join(baseDir, "hub-args.json"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(path.join(installDir, ".install-complete"), DEVICE_HUB_VERSION); - yield* fs.writeFileString( - entryPath, - `import http from 'node:http'; import fs from 'node:fs'; -const args=process.argv.slice(2); fs.writeFileSync(${JSON.stringify(argumentsPath)},JSON.stringify(args)); -http.createServer((req,res)=>res.end('ok')).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, - ); - const host = yield* LocalDeviceHost.make().pipe( - Effect.provide( - Layer.mergeAll( - ServerConfig.layerTest(baseDir, baseDir), - NetService.layer, - ProcessRunner.layer, - FetchHttpClient.layer, - ), - ), - Effect.provideService(HostProcess.Environment, { ...environment, HOME: baseDir }), - Effect.provideService(HostProcess.Platform, platform), - ); - yield* host.ensureReady(() => Effect.void).pipe(Effect.ensuring(host.stop)); - return (yield* fs - .readFileString(argumentsPath) - .pipe(Effect.map((contents) => JSON.parse(contents)))) as ReadonlyArray; - }); - expect((yield* hubArguments("linux")).slice(-2)).toEqual(["--platform", "android"]); - expect(yield* hubArguments("darwin")).not.toContain("--platform"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - it.effect("keeps local Android and iOS available without ffmpeg", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/deviceToolMaintenance.test.ts b/apps/server/src/device/deviceToolMaintenance.test.ts index 67da025672fa..dec86beb6bb4 100644 --- a/apps/server/src/device/deviceToolMaintenance.test.ts +++ b/apps/server/src/device/deviceToolMaintenance.test.ts @@ -97,6 +97,7 @@ describe.each([false, true])("device tool cleanup, flat=%s", (flat) => { }); }); +// regression: #12819 safely reclaim obsolete managed tool versions it.effect("maintenance failures retain safe context and the original process result", () => Effect.gen(function* () { const output = { diff --git a/apps/server/src/device/easProjectId.test.ts b/apps/server/src/device/easProjectId.test.ts index 787fd198bf36..42139f4eff1e 100644 --- a/apps/server/src/device/easProjectId.test.ts +++ b/apps/server/src/device/easProjectId.test.ts @@ -88,16 +88,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer for a config naming two projects, even with app.json", - files: { - "app.config.js": `export default () => ({ - extra: { eas: { projectId: process.env.PROD ? "${ID_A}" : "${ID_B}" } }, - });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, { case: "gives no answer when one app sets its project from a variable", files: { @@ -108,14 +98,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer when a comment's id sits beside a computed one", - files: { - "app.config.ts": `// projectId: "${ID_A}" - export default { extra: { eas: { projectId: process.env.EAS_PROJECT_ID } } };`, - }, - expected: Option.none(), - }, { case: "gives no answer when a config changes the project id after naming it", files: { @@ -129,74 +111,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "uses app.json when the config only mentions a longer identifier", - files: { - "app.config.js": `const easProjectIds = {}; - export default ({ config }) => ({ ...config, name: "Mobile", easProjectIds });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.some(ID_B), - }, - { - case: "gives no answer when a longer quoted key sits beside a computed project id", - files: { - "app.config.js": `const defaults = { easProjectId: "${ID_B}" }; - export default { extra: { eas: { projectId: process.env.EAS_PROJECT_ID ?? defaults.easProjectId } } };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config joins the project id from pieces", - files: { - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config joins the project id around a comment", - files: { - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" /* prefix */ + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, - { - case: "reads a project id a comment follows", - files: { - "app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A}" /* production */ // the only one - } }, - };`, - }, - expected: Option.some(ID_A), - }, - { - case: "gives no answer when a separator inside a comment precedes the rest of the id", - files: { - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix, continued (below) - + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a block comment with a separator precedes the rest of the id", - files: { - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" /* prefix, (see) */ + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, { case: "gives no answer when a spread after the project id may replace it", files: { @@ -205,30 +119,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer when a spread after the project id hides behind a comment", - files: { - "apps/mobile/app.config.js": `import { easOverrides } from "./app.shared.js"; - export default { extra: { eas: { projectId: "${ID_A}" /* } */, ...easOverrides } } };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a spread at the top of the config may replace the project id", - files: { - "apps/mobile/app.config.js": `import { brand } from "./app.shared.js"; - export default { extra: { eas: { projectId: "${ID_A}" } }, ...brand };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a spread in an enclosing object may replace the project id", - files: { - "apps/mobile/app.config.js": `import { extraOverrides } from "./app.shared.js"; - export default { extra: { eas: { projectId: "${ID_A}" }, ...extraOverrides } };`, - }, - expected: Option.none(), - }, { case: "gives no answer when the config is assigned over after naming the project id", files: { @@ -240,41 +130,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer when the config is assigned over across lines after naming the project id", - files: { - "app.config.js": `import { assign } from "lodash"; - export default ({ config }) => { - config.extra = { eas: { projectId: "${ID_A}" } }; - Object - .assign(config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}")); - return config; - };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a helper assigns over the config after naming the project id", - files: { - "app.config.js": `import { assign } from "lodash"; - export default ({ config }) => { - config.extra = { eas: { projectId: "${ID_A}" } }; - assign (config.extra.eas, JSON.parse(process.env.EAS_OVERRIDES ?? "{}")); - return config; - };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when the config is assigned over from defaults that name the project id", - files: { - "app.config.js": `module.exports = Object.assign( - { name: "Mobile", extra: { eas: { projectId: "${ID_A}" } } }, - require("./app.config.local.js"), - );`, - }, - expected: Option.none(), - }, { case: "gives no answer when a config spreads another file over app.json's project id", files: { @@ -283,37 +138,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer when a comment or parentheses sit between a spread and another file", - files: { - "app.config.js": `module.exports = ({ config }) => ({ - ...config, - .../* environment overrides */ require("./app.local.js"), - });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when nested parentheses sit between a spread and another file", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, ...((require("./app.local.js"))) });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when another service's project id sits beside a spread over app.json's", - files: { - "app.config.js": `module.exports = ({ config }) => ({ - ...config, - ...require("./app.local.js"), - web: { config: { firebase: { projectId: "my-firebase-app" } } }, - });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, { case: "gives no answer when a dynamic config spreads nothing, which may or may not use app.json", files: { @@ -322,68 +146,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "does not let a sibling win over an app whose config returns app.json's config changed", - files: { - "apps/mobile/app.config.js": `module.exports = ({ config }) => { - config.name = "Mobile"; - return config; - };`, - "apps/mobile/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a spread of an identifier starting with config may replace app.json's id", - files: { - "app.config.js": `const config$ = require("./app.local.js"); - module.exports = ({ config }) => ({ ...config, ...config$ });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when that identifier is spelled with a Unicode escape", - files: { - "app.config.js": `const config$ = require("./app.local.js"); - module.exports = ({ config }) => ({ ...config, ...config\\u0024 });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads the config but replaces extra", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: {} });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads the config but replaces a quoted extra", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, "extra": {} });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads the config but replaces a computed extra", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, ["extra"]: {} });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads the config but replaces extra by shorthand", - files: { - "app.config.js": `const extra = {};\nmodule.exports = ({ config }) => ({ ...config, extra });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, { case: "gives no answer when a config has more extra entries than are read", files: { @@ -392,60 +154,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "gives no answer when a config assigns extra in brackets after a literal id", - files: { - "app.config.js": `module.exports = ({ config }) => { - const c = { ...config, extra: { eas: { projectId: "${ID_A}" } } }; - c["extra"] = other(); - return c; - };`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config deletes from the config it spreads", - files: { - "app.config.js": `module.exports = ({ config }) => { - delete config.extra; - return { ...config }; - };`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads part of the config over it", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, ...config.extra.preview });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config spreads a property of the config instead of it", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config.preview });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when an extra entry spreads the whole config, not its extra", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config } });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when an eas entry spreads the config's extra, not its eas", - files: { - "app.config.js": `module.exports = ({ config }) => ({ ...config, extra: { ...config.extra, eas: { ...config.extra } } });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, { case: "reads app.json's id through extra and eas entries that spread their own", files: { @@ -457,48 +165,6 @@ it.effect.each([ }, expected: Option.some(ID_A), }, - { - case: "gives no answer when a config spreads the config but replaces eas", - files: { - "app.config.js": `module.exports = ({ config }) => ({ - ...config, - extra: { ...config.extra, eas: { build: { experimental: true } } }, - });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, - { - case: "reads app.json's project id through a config that only spreads the config itself", - files: { - "app.config.js": `module.exports = ({ config }) => ({ - ...config, - extra: { ...config.extra, eas: { ...(config.extra?.eas ?? {}) }, apiUrl: process.env.API_URL }, - });`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.some(ID_A), - }, - { - case: "gives no answer when a config may return another file's config on some builds", - files: { - "app.config.js": `module.exports = process.env.PROD - ? { extra: { eas: { projectId: "${ID_A}" } } } - : require("./app.config.staging.js");`, - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config assigns over the object holding the project id", - files: { - "app.config.js": `export default ({ config }) => { - const c = { extra: { eas: { projectId: "${ID_A}" } } }; - c.extra.eas = require("./eas.local.js"); - return c; - };`, - }, - expected: Option.none(), - }, { case: "reads a project id that follows spreads", files: { @@ -509,26 +175,6 @@ it.effect.each([ }, expected: Option.some(ID_A), }, - { - case: "gives no answer when a config joins the project id across lines", - files: { - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" - + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }, - expected: Option.none(), - }, - { - case: "gives no answer when a config builds the project id in a template", - files: { - "app.config.js": - "export default { extra: { eas: { projectId: `${process.env.EAS_PROJECT_ID}` } } };", - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }, - expected: Option.none(), - }, { case: "gives no answer when an app.json Expo reads as JSON5 cannot be decoded here", files: { @@ -548,14 +194,6 @@ it.effect.each([ }, expected: Option.none(), }, - { - case: "reads a static config that is not wrapped in `expo`", - files: { - "apps/mobile/app.config.json": JSON.stringify({ extra: { eas: { projectId: ID_A } } }), - "apps/README.md": "one app", - }, - expected: Option.some(ID_A), - }, { case: "does not guess between an unwrapped static config and a wrapped one", files: { @@ -578,23 +216,14 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(layer)), ); -it.effect("reads app.config.json like app.json", () => - Effect.gen(function* () { - const root = yield* workspace({ - "app.config.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }); - expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_B)); - }).pipe(Effect.scoped, Effect.provide(layer)), -); - -it.effect.each(["\r", "\u2028", "\u2029"])( - "gives no answer when a comment ending in %o precedes the rest of a joined id", - (terminator) => +it.effect( + "gives no answer when a comment ending in a non-LF line terminator precedes the rest of a joined id", + () => Effect.gen(function* () { // A line comment ends at any ECMAScript line terminator, not only LF. const root = yield* workspace({ "apps/mobile/app.config.ts": - `export default { extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix${terminator}` + + `export default { extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // prefix\u2028` + `+ "${ID_A.slice(9)}" } } };`, "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), }); @@ -602,81 +231,6 @@ it.effect.each(["\r", "\u2028", "\u2029"])( }).pipe(Effect.scoped, Effect.provide(layer)), ); -it.effect.each([ - { - case: "a long run of spaces after a spread", - source: `({ config }) => ({ ...${" ".repeat(255 * 1024)}config })`, - }, - { - case: "many spreads inside one comment", - source: `({ config }) => ({ .../*${".../*".repeat(30_000)}*/config })`, - }, - { - case: "many spreads inside line comments", - source: `({ config }) => ({ ...//${" ...//".repeat(30_000)}\nconfig })`, - }, -])("reads $case at once", ({ source }) => - Effect.gen(function* () { - const root = yield* workspace({ - "app.config.js": `module.exports = ${source};`, - "app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_A } } } }), - }); - const startedAt = performance.now(); - expect(yield* findEasProjectId(root)).toEqual(Option.some(ID_A)); - expect(performance.now() - startedAt).toBeLessThan(10_000); - }).pipe(Effect.scoped, Effect.provide(layer)), -); - -it.effect("gives no answer at once for a config with more entries than one can have", () => - Effect.gen(function* () { - // Just under the size bound, every entry followed by an unterminated - // comment would still take seconds; the entry count stops it first. - const entry = `projectId: "${ID_A.slice(0, 9)}" /* `; - const root = yield* workspace({ - "apps/mobile/app.config.ts": entry.repeat(Math.floor((255 * 1024) / entry.length)), - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }); - const startedAt = performance.now(); - expect(yield* findEasProjectId(root)).toEqual(Option.none()); - expect(performance.now() - startedAt).toBeLessThan(10_000); - }).pipe(Effect.scoped, Effect.provide(layer)), -); - -it.effect("gives no answer at once for a config too large to be one", () => - Effect.gen(function* () { - // Each entry followed by an unterminated comment scans to the end of the - // file: quadratic on a file made for it. A config this large is unknown. - const entry = `projectId: "${ID_A.slice(0, 9)}" /* `; - const root = yield* workspace({ - "apps/mobile/app.config.ts": entry.repeat(Math.ceil((1024 * 1024) / entry.length)), - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }); - // The bound is loose for a loaded test machine; the slow cases take minutes. - const startedAt = performance.now(); - expect(yield* findEasProjectId(root)).toEqual(Option.none()); - expect(performance.now() - startedAt).toBeLessThan(10_000); - }).pipe(Effect.scoped, Effect.provide(layer)), -); - -it.effect("answers at once for a long comment before the rest of a joined id", () => - Effect.gen(function* () { - // A comment with many slashes before a continuation made an earlier - // matcher try every way to split it into comments: minutes, not ms. - const urls = Array.from({ length: 40 }, () => "https://expo.dev").join(" "); - const root = yield* workspace({ - "apps/mobile/app.config.ts": `export default { - extra: { eas: { projectId: "${ID_A.slice(0, 9)}" // ${urls} - + "${ID_A.slice(9)}" } }, - };`, - "apps/kiosk/app.json": JSON.stringify({ expo: { extra: { eas: { projectId: ID_B } } } }), - }); - // The bound is loose for a loaded test machine; the slow cases take minutes. - const startedAt = performance.now(); - expect(yield* findEasProjectId(root)).toEqual(Option.none()); - expect(performance.now() - startedAt).toBeLessThan(10_000); - }).pipe(Effect.scoped, Effect.provide(layer)), -); - it.effect("gives no answer when an app's config is there but cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/mjpegStillFrame.test.ts b/apps/server/src/device/mjpegStillFrame.test.ts deleted file mode 100644 index 8867dec3bf5a..000000000000 --- a/apps/server/src/device/mjpegStillFrame.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Queue from "effect/Queue"; -import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; - -import { STILL_FRAME_DELAY, repeatStillFrame } from "./mjpegStillFrame.ts"; - -const part = (body: string) => - Buffer.from( - `--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.length}\r\n\r\n${body}\r\n`, - ); - -const viewer = Effect.gen(function* () { - const upstream = yield* Queue.unbounded(); - let received = ""; - const fiber = yield* repeatStillFrame(Stream.fromQueue(upstream)).pipe( - Stream.runForEach((chunk) => - Effect.sync(() => { - received += Buffer.from(chunk).toString(); - }), - ), - Effect.forkChild, - ); - const send = (...chunks: ReadonlyArray) => - Effect.gen(function* () { - for (const chunk of chunks) yield* Queue.offer(upstream, chunk); - yield* Effect.yieldNow; - }); - const finish = Effect.gen(function* () { - yield* Queue.end(upstream); - yield* Fiber.join(fiber); - return received; - }); - return { send, finish }; -}); - -describe("repeatStillFrame", () => { - it.effect("re-sends only the newest complete frame once when the screen stays still", () => - Effect.gen(function* () { - const { send, finish } = yield* viewer; - yield* send(part("first")); - yield* TestClock.adjust(STILL_FRAME_DELAY); - yield* send(Buffer.concat([part("second"), part("third")])); - yield* TestClock.adjust(STILL_FRAME_DELAY); - yield* TestClock.adjust(STILL_FRAME_DELAY); - expect(yield* finish).toBe( - `${part("first")}${part("first")}${part("second")}${part("third")}${part("third")}`, - ); - }), - ); - - it.effect("never repeats into a frame that is still arriving", () => - Effect.gen(function* () { - const { send, finish } = yield* viewer; - const second = part("second"); - // The pause lands inside the second part's headers. - yield* send(part("first"), second.subarray(0, 20)); - yield* TestClock.adjust(STILL_FRAME_DELAY); - yield* send(second.subarray(20)); - yield* TestClock.adjust(STILL_FRAME_DELAY); - expect(yield* finish).toBe(`${part("first")}${second}${second}`); - }), - ); - - it.effect("recognizes a frame whose final CRLF arrives in a later chunk", () => - Effect.gen(function* () { - const { send, finish } = yield* viewer; - const first = part("first"); - yield* send(first.subarray(0, -1), first.subarray(-1)); - yield* TestClock.adjust(STILL_FRAME_DELAY); - expect(yield* finish).toBe(`${first}${first}`); - }), - ); - - it.effect("passes through bodies without the hub's part framing", () => - Effect.gen(function* () { - const { send, finish } = yield* viewer; - const unframed = Buffer.from("--frame\r\nContent-Type: image/jpeg\r\n\r\nbody\r\n"); - yield* send(unframed); - yield* TestClock.adjust(STILL_FRAME_DELAY); - yield* send(part("later")); - yield* TestClock.adjust(STILL_FRAME_DELAY); - expect(yield* finish).toBe(`${unframed}${part("later")}`); - }), - ); -}); diff --git a/apps/server/src/device/mjpegStillFrame.ts b/apps/server/src/device/mjpegStillFrame.ts index 4792bf083257..7cbe30075912 100644 --- a/apps/server/src/device/mjpegStillFrame.ts +++ b/apps/server/src/device/mjpegStillFrame.ts @@ -11,7 +11,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; -export const STILL_FRAME_DELAY = Duration.millis(100); +const STILL_FRAME_DELAY = Duration.millis(100); const HEADER_END = Buffer.from("\r\n\r\n"); const MAX_HEADER_BYTES = 16 * 1024; diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 713e0a566280..e3af344e6370 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -1,14 +1,13 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - verifies generated remote scripts using real shell and Node processes. import * as Effect from "effect/Effect"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { describe, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; -import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; -import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; +import { quoteRemoteArg, remoteDeviceEnvironment } from "./sshDeviceScript.ts"; const exec = NodeUtil.promisify(NodeChildProcess.execFile); @@ -46,222 +45,3 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () }); }), ); - -describe("remote helper lifecycle", () => { - it.effect("reuses its own healthy helpers and stops only its own runtime", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - yield* Effect.promise(async () => { - const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); - const bin = NodePath.join(home, "bin"); - await NodeFSP.mkdir(bin); - await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - await NodeFSP.writeFile( - NodePath.join(bin, "emulator"), - "#!/bin/sh\nprintf 'emulated webcam none\\n' >&2\n", - { mode: 0o755 }, - ); - const root = NodePath.join(home, ".t3/device"); - const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); - const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); - const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); - const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs"); - await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), { - recursive: true, - }); - await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), { - recursive: true, - }); - await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION); - await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); - await NodeFSP.writeFile( - hub, - `import http from 'node:http'; import fs from 'node:fs'; -if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} -const args=process.argv.slice(2); fs.writeFileSync('hub-args.json',JSON.stringify(args)); fs.writeFileSync('hub-camera-args',process.env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS || ''); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, - ); - await NodeFSP.writeFile( - agent, - `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process'; -const args=process.argv.slice(2); -const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1]; -const file=path.join(state,'daemon.json'); -if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } -else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } -else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } -`, - ); - await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs")); - const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; - const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; - // Each owner stands in for a host on a different platform. - const hostPlatforms: Record = { one: "linux", two: "darwin" }; - const hubArgs = async (owner: string): Promise> => - JSON.parse( - await NodeFSP.readFile(NodePath.join(root, `hosts/${owner}/hub-args.json`), "utf8"), - ); - let invocation = 0; - const invoke = async ( - owner: string, - mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", - upgraded = false, - ) => { - const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); - await NodeFSP.writeFile( - file, - `Object.defineProperty(process, 'platform', { value: ${JSON.stringify(hostPlatforms[owner])} });\n` + - `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + - remoteDeviceScript(owner, mode) - .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) - .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), - ); - const result = await exec(process.execPath, [file], { - env: { - ...process.env, - HOME: home, - PATH: `${bin}:${process.env.PATH}`, - SERVE_EMU_FFMPEG: NodePath.join(home, "missing-ffmpeg"), - ANDROID_HOME: "", - EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: "-memory 4096", - }, - }); - return result.stdout ? JSON.parse(result.stdout) : null; - }; - const inventory = await invoke("one", "probe"); - expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]); - expect(inventory.tools.hub.runningVersion).toBeNull(); - expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]); - await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow(); - const template = NodePath.join(home, "hub-template"); - await NodeFSP.cp(hubDir, template, { recursive: true }); - await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); - const installLock = hubDir + ".lock"; - await NodeFSP.symlink("2147483647:exited-installer", installLock); - await NodeFSP.writeFile( - NodePath.join(bin, "npm"), - `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, - { mode: 0o755 }, - ); - await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); - await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); - await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); - await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); - try { - const [manual, concurrent] = await Promise.all([ - invoke("one", "start"), - invoke("one", "start"), - ]); - expect(concurrent.hubPort).toBe(manual.hubPort); - expect(manual.daemonPort).toBeUndefined(); - expect((await hubArgs("one")).slice(-2)).toEqual(["--platform", "android"]); - expect( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub-camera-args"), "utf8"), - ).toBe("-camera-front emulated -camera-back emulated -memory 4096"); - // A hub started before discovery was filtered has no recorded platform. - const hubFile = NodePath.join(root, "hosts/one/hub.json"); - const { platform: _platform, ...unfilteredHub } = JSON.parse( - await NodeFSP.readFile(hubFile, "utf8"), - ); - await NodeFSP.writeFile(hubFile, JSON.stringify(unfilteredHub)); - const filtered = await invoke("one", "start"); - expect(filtered.hubPort).not.toBe(manual.hubPort); - expect(await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")).toContain( - `${unfilteredHub.pid}\n`, - ); - expect((await invoke("one", "start")).hubPort).toBe(filtered.hubPort); - await expect( - NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), - ).rejects.toThrow(); - const [first, concurrentAgent] = await Promise.all([ - invoke("one", "agent-start"), - invoke("one", "agent-start"), - ]); - expect(concurrentAgent.hubPort).toBe(first.hubPort); - expect(concurrentAgent.daemonPort).toBe(first.daemonPort); - const running = await invoke("one", "probe"); - expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION); - expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION); - const second = await invoke("two", "agent-start"); - const reused = await invoke("one", "agent-start"); - expect(reused.hubPort).toBe(first.hubPort); - expect(await hubArgs("two")).not.toContain("--platform"); - expect((await invoke("two", "start")).hubPort).toBe(second.hubPort); - expect(reused.daemonPort).toBe(first.daemonPort); - expect(second.hubPort).not.toBe(first.hubPort); - expect(second.daemonPort).not.toBe(first.daemonPort); - const firstHub = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), - ); - const secondHub = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), - ); - await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); - let repaired = await invoke("one", "agent-start"); - expect(repaired.hubPort).not.toBe(first.hubPort); - const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")) - .trim() - .split("\n"); - expect(stopped).toContain(String(firstHub.pid)); - expect(stopped).not.toContain(String(secondHub.pid)); - const previousDaemon = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), - ); - for (const [source, name, version] of [ - [hubDir, "expo-device-hub", nextHubVersion], - [agentDir, "agent-device", nextAgentVersion], - ]) { - const destination = NodePath.join(root, `tools/${name}@${version}`); - await NodeFSP.cp(source!, destination, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!); - } - const upgraded = await invoke("one", "agent-start", true); - expect(upgraded.entryPath).toContain(nextAgentVersion); - const upgradedHub = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), - ); - expect(upgradedHub.entryPath).toContain(nextHubVersion); - const upgradedDaemon = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), - ); - expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid); - expect(await invoke("one", "agent-start", true)).toEqual(upgraded); - await NodeFSP.writeFile( - NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`), - "", - ); - repaired = await invoke("one", "agent-start", true); - expect( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), - ).toBe(String(upgradedDaemon.pid)); - expect(repaired.daemonPort).not.toBe(upgraded.daemonPort); - const originalScript = remoteDeviceScript("one", "stop-agent"); - const upgradedStop = NodePath.join(home, "upgraded-stop.cjs"); - await NodeFSP.writeFile( - upgradedStop, - originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), - ); - await exec(process.execPath, [upgradedStop], { - env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, - }); - const daemon = JSON.parse( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), - ); - expect( - await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), - ).toBe(String(daemon.pid)); - expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true); - await invoke("one", "stop"); - expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); - expect( - JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8")) - .owner, - ).toBe("two"); - } finally { - await invoke("one", "stop").catch(() => {}); - await invoke("two", "stop").catch(() => {}); - await NodeFSP.rm(home, { recursive: true, force: true }); - } - }); - }), - ); -}); diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index aa61651e4128..e4938e5f8ced 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -62,57 +62,6 @@ function layerTelemetryFor( } describe("ProcessDiagnostics", () => { - it.effect("projects live process data from resource telemetry", () => - Effect.gen(function* () { - const snapshot = makeNativeSnapshot([ - { - pid: process.pid, - ppid: 1, - startTimeMs: 1_000, - runTimeMs: 60_000, - name: "node", - command: "t3 server", - status: "Running", - cpuPercent: 0, - cpuTimeMs: 100, - residentBytes: 1_024, - virtualBytes: 2_048, - ioReadBytes: 100, - ioWriteBytes: 200, - ioSemantics: "storage", - }, - { - pid: 4_242, - ppid: process.pid, - startTimeMs: 2_000, - runTimeMs: 4_000, - name: "agent", - command: "codex app-server", - status: "Running", - cpuPercent: 1.5, - cpuTimeMs: 60, - residentBytes: 2_048, - virtualBytes: 4_096, - ioReadBytes: 300, - ioWriteBytes: 400, - ioSemantics: "storage", - }, - ]); - const layerTelemetry = layerTelemetryFor(snapshot); - const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(layerTelemetry)); - - const diagnostics = yield* Effect.gen(function* () { - const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; - return yield* processDiagnostics.read; - }).pipe(Effect.provide(layer)); - - expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); - expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000); - expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5); - expect(diagnostics.processes[0]?.rssBytes).toBe(2_048); - }), - ); - it.effect("rejects stale process identities before signaling", () => Effect.gen(function* () { const snapshot = makeNativeSnapshot([]); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 624da9dd618c..97156800e3db 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -143,15 +143,6 @@ describe("TraceDiagnostics", () => { }), ); - it.effect("returns a not-found diagnostic when no files are available", () => - Effect.gen(function* () { - const diagnostics = yield* readTraces({}); - - assert.equal(diagnostics.recordCount, 0); - assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found"); - }), - ); - it.effect("streams rotated files into the same result as reading them whole", () => Effect.gen(function* () { const files = new Map([ @@ -224,34 +215,6 @@ describe("TraceDiagnostics", () => { }), ); - it.effect("preserves full failure causes and log messages", () => - Effect.sync(() => { - const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); - const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); - const diagnostics = aggregateLines([ - record({ - name: "VcsProcess.run", - traceId: "trace-long", - spanId: "span-long", - startMs: 1_000, - durationMs: 25, - exit: { _tag: "Failure", cause: longCause }, - events: [ - { - name: longMessage, - timeUnixNano: ns(1_010), - attributes: { "effect.logLevel": "Warning" }, - }, - ], - }), - ]); - - assert.equal(diagnostics.latestFailures[0]?.cause, longCause); - assert.equal(diagnostics.commonFailures[0]?.cause, longCause); - assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage); - }), - ); - it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { const readFailure = PlatformError.systemError({ @@ -305,44 +268,4 @@ describe("TraceDiagnostics", () => { }); }), ); - - it.effect("keeps only the top spans, failures, and warning logs from large inputs", () => - Effect.sync(() => { - const indexes = Array.from({ length: 30 }, (_, step) => (step * 7) % 30); - const diagnostics = aggregateLines( - indexes.map((index) => - record({ - name: `span-${index}`, - traceId: `trace-${index}`, - spanId: `span-${index}`, - startMs: index * 1_000, - durationMs: index, - exit: { _tag: "Failure", cause: "Provider crashed" }, - events: [ - { - name: `warning ${index}`, - timeUnixNano: ns(index * 1_000), - attributes: { "effect.logLevel": "Warning" }, - }, - ], - }), - ), - ); - const newestTwenty = Array.from({ length: 20 }, (_, rank) => `trace-${29 - rank}`); - - assert.equal(diagnostics.recordCount, 30); - assert.deepStrictEqual( - diagnostics.slowestSpans.map((span) => span.durationMs), - [29, 28, 27, 26, 25, 24, 23, 22, 21, 20], - ); - assert.deepStrictEqual( - diagnostics.latestFailures.map((failure) => failure.traceId), - newestTwenty, - ); - assert.deepStrictEqual( - diagnostics.latestWarningAndErrorLogs.map((log) => log.traceId), - newestTwenty, - ); - }), - ); }); diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts index b833c47d8f4f..f5447cc5675f 100644 --- a/apps/server/src/entrypoint.test.ts +++ b/apps/server/src/entrypoint.test.ts @@ -12,23 +12,6 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); describe("isEntrypoint", () => { - it("uses the runtime answer when Node provides one", () => { - expect( - isEntrypoint({ - moduleUrl: "file:///somewhere/bin.mjs", - entryPath: "/elsewhere/other.mjs", - runtimeMain: true, - }), - ).toBe(true); - expect( - isEntrypoint({ - moduleUrl: "file:///somewhere/bin.mjs", - entryPath: "/somewhere/bin.mjs", - runtimeMain: false, - }), - ).toBe(false); - }); - it("matches the entrypoint path when the runtime has no import.meta.main", () => { // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined. const dir = makeTempDir(); @@ -80,14 +63,4 @@ describe("isEntrypoint", () => { }), ).toBe(false); }); - - it("stays false when there is no entrypoint argument", () => { - expect( - isEntrypoint({ - moduleUrl: "file:///somewhere/bin.mjs", - entryPath: undefined, - runtimeMain: undefined, - }), - ).toBe(false); - }); }); diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 361ba2d02b9c..793f2d1d59fc 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -98,15 +98,6 @@ const virtualInterface = (address: string) => [ ]; describe("resolveBoundEndpoints", () => { - it("lists nothing for a loopback-only server", () => { - expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual( - [], - ); - expect( - resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }), - ).toEqual([]); - }); - it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => { expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([ { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, @@ -146,12 +137,6 @@ describe("resolveBoundEndpoints", () => { ]); }); - it("lists only the bound address for a specific bind", () => { - expect( - resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }), - ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); - }); - it("never reports a host name, which can resolve to another machine per client", () => { for (const host of ["server.local", "devbox", "devbox.home.arpa"]) { expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index de3d0db034dc..3e66d5f707a0 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- replace the native OS lookup and sync its ESM bindings. import * as NodeModule from "node:module"; -import type * as NodeOS from "node:os"; import { it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NetService from "@t3tools/shared/Net"; @@ -14,8 +13,6 @@ import { afterEach, describe, expect, vi } from "vite-plus/test"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; -const mutableOs = NodeModule.createRequire(import.meta.url)("node:os") as typeof NodeOS; - afterEach(() => { vi.restoreAllMocks(); NodeModule.syncBuiltinESMExports(); @@ -140,21 +137,6 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("orders the tailnet name before the mDNS name", () => - Effect.gen(function* () { - const targets = yield* resolveTargets({ - sshd: { ipv4: true, ipv6: true }, - tailscale: TAILSCALE_UP, - hostname: "bb-1", - username: "t3-user", - }); - expect(targets).toEqual([ - { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, - { kind: "mdns", host: "bb-1.local", username: "t3-user" }, - ]); - }), - ); - it.effect("accepts an sshd bound to IPv6 loopback only", () => Effect.gen(function* () { const targets = yield* resolveTargets({ @@ -166,47 +148,4 @@ describe("RemoteOpenTargets", () => { expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]); }), ); - - it.effect("falls back to mDNS alone when tailscale is unavailable", () => - Effect.gen(function* () { - const targets = yield* resolveTargets({ - sshd: { ipv4: true, ipv6: false }, - tailscale: TAILSCALE_DOWN, - hostname: "bb-1", - username: "t3-user", - }); - expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]); - }), - ); - - it.effect("shortens an FQDN hostname to its first label for mDNS", () => - Effect.gen(function* () { - const targets = yield* resolveTargets({ - sshd: { ipv4: true, ipv6: true }, - tailscale: TAILSCALE_DOWN, - hostname: "bb-1.example.com", - username: "t3-user", - }); - expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]); - }), - ); - - it.effect("still advertises hostname targets when the OS login lookup fails", () => - Effect.gen(function* () { - const userInfo = vi.spyOn(mutableOs, "userInfo").mockImplementation(() => { - throw new Error("No passwd entry for this UID"); - }); - NodeModule.syncBuiltinESMExports(); - const targets = yield* resolveTargets({ - sshd: { ipv4: true, ipv6: true }, - tailscale: TAILSCALE_UP, - hostname: "bb-1", - }); - expect(userInfo).toHaveBeenCalled(); - expect(targets).toEqual([ - { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, - { kind: "mdns", host: "bb-1.local" }, - ]); - }), - ); }); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index f7d119abd236..a894b8359cf7 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -6,8 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -22,10 +20,6 @@ import { import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; -const isServerEnvironmentIdPersistenceError = Schema.is( - ServerEnvironment.ServerEnvironmentIdPersistenceError, -); - const layerServerEnvironment = (baseDir: string) => ServerEnvironment.layer.pipe( Layer.provide(ServerSecretStore.layer), @@ -273,106 +267,4 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe(Effect.provide(layerTest)); }), ); - - it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-desktop-update-test-", - }); - const serverConfig = yield* makeServerConfig(baseDir); - yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); - - const describeWith = (overrides: Partial) => - Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe( - Effect.provide( - ServerEnvironment.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })), - ), - ), - ); - - const withFd = yield* describeWith({ mode: "desktop", desktopTelemetryControlFd: 5 }); - expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); - expect(withFd.capabilities.desktopAppUpdate).toBe(true); - expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true); - expect(withFd.capabilities.serverUpdateThreadContinuation).toBeUndefined(); - - const withoutFd = yield* describeWith({ mode: "desktop" }); - expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); - expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined(); - expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined(); - expect(withoutFd.capabilities.serverUpdateThreadContinuation).toBeUndefined(); - - const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 }); - expect(web.capabilities.desktopAppUpdate).toBeUndefined(); - }), - ); - - it.effect("structures persisted environment id filesystem failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-error-test-", - }); - const serverConfig = yield* makeServerConfig(baseDir); - const environmentIdPath = serverConfig.environmentIdPath; - const tempPath = `${environmentIdPath}.tmp`; - const methodByOperation = { - check: "exists", - read: "readFileString", - write: "writeFileString", - } as const; - - for (const operation of ["check", "read", "write"] as const) { - const writeAttempts: string[] = []; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: methodByOperation[operation], - description: "permission denied", - pathOrDescriptor: environmentIdPath, - }); - const layerFailingFileSystem = FileSystem.layerNoop({ - exists: () => - operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), - readFileString: () => Effect.fail(cause), - makeTempFileScoped: () => Effect.succeed(tempPath), - writeFileString: (path) => { - writeAttempts.push(path); - return Effect.fail(cause); - }, - }); - - const error = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe( - Effect.provide( - ServerEnvironment.layer.pipe( - Layer.provide(layerEmptySecretStore), - Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), layerFailingFileSystem)), - ), - ), - Effect.flip, - ); - - expect(isServerEnvironmentIdPersistenceError(error)).toBe(true); - if (!isServerEnvironmentIdPersistenceError(error)) { - throw error; - } - expect(error.operation).toBe(operation); - expect(error.environmentIdPath).toBe(environmentIdPath); - expect(error.cause).toBe(cause); - expect(error.message).toBe( - `Server environment ID ${operation} failed at '${environmentIdPath}'.`, - ); - expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); - } - }), - ); }); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts deleted file mode 100644 index 7fd418e2821e..000000000000 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { afterEach, describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; -import * as Schema from "effect/Schema"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { vi } from "vite-plus/test"; - -import * as ProcessRunner from "../processRunner.ts"; -import * as ServerEnvironmentLabel from "./ServerEnvironmentLabel.ts"; - -const isServerEnvironmentLabelFileError = Schema.is( - ServerEnvironmentLabel.ServerEnvironmentLabelFileError, -); -const isServerEnvironmentLabelCommandError = Schema.is( - ServerEnvironmentLabel.ServerEnvironmentLabelCommandError, -); - -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} - -const runMock = vi.fn(); - -const layerProcessRunnerTest = Layer.succeed( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ - run: (input) => runMock(input), - }), -); -const layerNoopFileSystem = FileSystem.layerNoop({}); -const layerTest = Layer.merge(layerNoopFileSystem, layerProcessRunnerTest); -const layerLinuxMachineInfo = Layer.merge( - layerProcessRunnerTest, - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === "/etc/machine-info"), - readFileString: (path) => - path === "/etc/machine-info" - ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') - : Effect.succeed(""), - }), -); -const withHostPlatform = ( - layer: Layer.Layer, - platform: NodeJS.Platform, - hostname: string, -) => - Layer.mergeAll( - layer, - Layer.succeed(HostProcess.Platform, platform), - Layer.succeed(HostProcess.Hostname, hostname), - ); - -afterEach(() => { - runMock.mockReset(); -}); - -describe("resolveServerEnvironmentLabel", () => { - it.effect("uses hostname fallback regardless of launch mode", () => - Effect.gen(function* () { - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerTest, "win32", "macbook-pro"))); - - expect(result).toBe("macbook-pro"); - }), - ); - - it.effect("prefers the macOS ComputerName", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " Julius's MacBook Pro \n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerTest, "darwin", "macbook-pro"))); - - expect(result).toBe("Julius's MacBook Pro"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "scutil", - args: ["--get", "ComputerName"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => - Effect.gen(function* () { - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerLinuxMachineInfo, "linux", "buildbox"))); - - expect(result).toBe("Build Agent 01"); - expect(runMock).not.toHaveBeenCalled(); - }), - ); - - it.effect("falls back to hostnamectl pretty hostname on Linux", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: "CI Runner\n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerTest, "linux", "runner-01"))); - - expect(result).toBe("CI Runner"); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ - command: "hostnamectl", - args: ["--pretty"], - timeoutBehavior: "timedOutResult", - }), - ); - }), - ); - - it.effect("falls back to the hostname when friendly labels are unavailable", () => - Effect.gen(function* () { - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerTest, "win32", "JULIUS-LAPTOP"))); - - expect(result).toBe("JULIUS-LAPTOP"); - }), - ); - - it.effect("falls back to the hostname when the friendly-label command is missing", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - const spawnCause = new Error("spawn scutil ENOENT"); - const processError = new ProcessRunner.ProcessSpawnError({ - command: "scutil", - argumentCount: 2, - cause: spawnCause, - }); - runMock.mockReturnValueOnce(Effect.fail(processError)); - - return Effect.gen(function* () { - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }); - - expect(result).toBe("macbook-pro"); - expect(logs[0]?.message).toEqual([ - "Failed to run environment-label probe 'macos-computer-name' with scutil.", - ]); - const error = logs[0]?.annotations.cause; - expect(isServerEnvironmentLabelCommandError(error)).toBe(true); - if (isServerEnvironmentLabelCommandError(error)) { - expect(error.probe).toBe("macos-computer-name"); - expect(error.executable).toBe("scutil"); - expect(error.argumentCount).toBe(2); - expect(error).not.toHaveProperty("args"); - expect(error.message).not.toContain("--get"); - expect(error.message).not.toContain("ComputerName"); - expect(error.cause).toBe(processError); - expect(processError.cause).toBe(spawnCause); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - withHostPlatform(layerTest, "darwin", "macbook-pro"), - Logger.layer([logger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Debug"), - ), - ), - ); - }); - - it.effect("continues to hostnamectl after a machine-info inspect failure", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - const fileCause = new Error("permission denied"); - const platformError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - pathOrDescriptor: "/etc/machine-info", - cause: fileCause, - }); - const layerFileSystem = FileSystem.layerNoop({ - exists: () => Effect.fail(platformError), - }); - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: "CI Runner\n", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - return Effect.gen(function* () { - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }); - - expect(result).toBe("CI Runner"); - expect(logs[0]?.message).toEqual([ - "Failed to inspect environment-label file at /etc/machine-info.", - ]); - const error = logs[0]?.annotations.cause; - expect(isServerEnvironmentLabelFileError(error)).toBe(true); - if (isServerEnvironmentLabelFileError(error)) { - expect(error.operation).toBe("inspect"); - expect(error.path).toBe("/etc/machine-info"); - expect(error.cause).toBe(platformError); - expect(platformError.cause).toBe(fileCause); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - withHostPlatform( - Layer.merge(layerProcessRunnerTest, layerFileSystem), - "linux", - "buildbox", - ), - Logger.layer([logger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Debug"), - ), - ), - ); - }); - - it.effect("falls back to the cwd basename when the hostname is blank", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - Effect.succeed({ - stdout: " ", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - const result = yield* ServerEnvironmentLabel.resolveServerEnvironmentLabel({ - cwdBaseName: "t3code", - }).pipe(Effect.provide(withHostPlatform(layerTest, "linux", " "))); - - expect(result).toBe("t3code"); - }), - ); -}); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts deleted file mode 100644 index 198aa4ed5583..000000000000 --- a/apps/server/src/environment/ServerEnvironmentMachine.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { afterEach, describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { vi } from "vite-plus/test"; - -import * as ProcessRunner from "../processRunner.ts"; -import { - detectServerEnvironmentMachineKind, - machineKindFromAppleProductName, - machineKindFromDmi, -} from "./ServerEnvironmentMachine.ts"; - -const runMock = vi.fn(); - -const layerProcessRunnerTest = Layer.succeed( - ProcessRunner.ProcessRunner, - ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }), -); - -const processOutput = (stdout: string, code = 0) => - Effect.succeed({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(code), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }); - -const layerDmiFileSystem = (files: Readonly>) => - FileSystem.layerNoop({ - readFileString: (path) => { - const name = path.slice(path.lastIndexOf("/") + 1); - return name in files - ? Effect.succeed(files[name]!) - : Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: path, - cause: new Error("ENOENT"), - }), - ); - }, - }); - -const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => - Layer.mergeAll(layerProcessRunnerTest, fileSystem, Layer.succeed(HostProcess.Platform, platform)); - -afterEach(() => { - runMock.mockReset(); -}); - -describe("machineKindFromAppleProductName", () => { - it("maps marketing names and model identifiers", () => { - expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini"); - expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini"); - expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio"); - expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop"); - expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop"); - expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop"); - expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop"); - }); - - it("returns null for Apple silicon model identifiers, which carry no product family", () => { - expect(machineKindFromAppleProductName("Mac16,10")).toBeNull(); - }); -}); - -describe("machineKindFromDmi", () => { - it("prefers virtualization markers over chassis type", () => { - expect( - machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }), - ).toBe("cloud"); - expect( - machineKindFromDmi({ - chassisType: "3", - sysVendor: "Microsoft Corporation", - productName: "Virtual Machine", - }), - ).toBe("cloud"); - expect( - machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }), - ).toBe("cloud"); - }); - - it("does not treat Microsoft hardware as a VM", () => { - expect( - machineKindFromDmi({ - chassisType: "9", - sysVendor: "Microsoft Corporation", - productName: "Surface Laptop 5", - }), - ).toBe("laptop"); - }); - - it("maps SMBIOS chassis codes", () => { - expect( - machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }), - ).toBe("desktop"); - expect( - machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }), - ).toBe("laptop"); - expect( - machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }), - ).toBe("server"); - expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull(); - expect( - machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }), - ).toBeNull(); - }); - - it("recognizes Apple hardware running Linux", () => { - expect( - machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }), - ).toBe("mac-studio"); - }); -}); - -describe("detectServerEnvironmentMachineKind", () => { - it.effect("reads the IOKit product name on macOS", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce( - processOutput( - '+-o product \n {\n "product-name" = <"Mac mini (2024)">\n }\n', - ), - ); - - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide(withPlatform("darwin")), - ); - - expect(result).toBe("mac-mini"); - expect(runMock).toHaveBeenCalledTimes(1); - expect(runMock).toHaveBeenCalledWith( - expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }), - ); - }), - ); - - it.effect("falls back to hw.model when IOKit has no product node", () => - Effect.gen(function* () { - runMock.mockReturnValueOnce(processOutput("", 1)); - runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n")); - - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide(withPlatform("darwin")), - ); - - expect(result).toBe("laptop"); - expect(runMock).toHaveBeenLastCalledWith( - expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }), - ); - }), - ); - - it.effect("returns null when both macOS probes fail", () => - Effect.gen(function* () { - runMock.mockImplementation((input) => - Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: new Error("ENOENT"), - }), - ), - ); - - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide(withPlatform("darwin")), - ); - - expect(result).toBeNull(); - expect(runMock).toHaveBeenCalledTimes(2); - }), - ); - - it.effect("reads DMI on Linux", () => - Effect.gen(function* () { - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide( - withPlatform( - "linux", - layerDmiFileSystem({ - chassis_type: "3\n", - sys_vendor: "GMKtec\n", - product_name: "NucBox K8 Plus\n", - }), - ), - ), - ); - - expect(result).toBe("desktop"); - expect(runMock).not.toHaveBeenCalled(); - }), - ); - - it.effect("recognizes WSL before its Hyper-V DMI identity", () => - Effect.gen(function* () { - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide( - withPlatform( - "linux", - layerDmiFileSystem({ - osrelease: "5.15.153.1-microsoft-standard-WSL2\n", - chassis_type: "3\n", - sys_vendor: "Microsoft Corporation\n", - product_name: "Virtual Machine\n", - }), - ), - ), - ); - - expect(result).toBe("linux"); - }), - ); - - it.effect("returns null on Linux without DMI (containers, ARM boards)", () => - Effect.gen(function* () { - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide(withPlatform("linux", layerDmiFileSystem({}))), - ); - - expect(result).toBeNull(); - }), - ); - - it.effect("skips detection on other platforms", () => - Effect.gen(function* () { - const result = yield* detectServerEnvironmentMachineKind().pipe( - Effect.provide(withPlatform("win32")), - ); - - expect(result).toBeNull(); - expect(runMock).not.toHaveBeenCalled(); - }), - ); -}); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index 02fa38ff8c98..1aa0f330aa5d 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -67,7 +67,7 @@ function normalize(value: string | null | undefined): string | null { return trimmed && trimmed.length > 0 ? trimmed : null; } -export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { +function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, ""); if (normalized.startsWith("macmini")) return "mac-mini"; if (normalized.startsWith("macstudio")) return "mac-studio"; @@ -76,7 +76,7 @@ export function machineKindFromAppleProductName(name: string): EnvironmentMachin return null; } -export function machineKindFromDmi(input: { +function machineKindFromDmi(input: { readonly chassisType: string | null; readonly sysVendor: string | null; readonly productName: string | null; diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index 7287425c3902..e6b2e1bc6792 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -1,11 +1,10 @@ import { EnvironmentThemeFile } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -68,33 +67,6 @@ const currentThemes = Effect.gen(function* () { }); it.layer(NodeServices.layer)("environment theme", (it) => { - it.effect("publishes nothing when the machine has no theme files", () => - withEnvironmentThemes( - {}, - Effect.gen(function* () { - assert.deepEqual(yield* currentThemes, []); - }), - ), - ); - - it.effect("publishes each file under its filename as the id", () => - withEnvironmentThemes( - { - "nightfall.json": encodeThemeFile(NIGHTFALL_THEME), - "shared-light.json": encodeThemeFile(SHARED_THEME), - }, - Effect.gen(function* () { - const themes = yield* currentThemes; - assert.deepEqual( - themes.map((theme) => theme.id), - ["nightfall", "shared-light"], - ); - assert.deepEqual(themes[0], { id: "nightfall", ...NIGHTFALL_THEME }); - assert.deepEqual(themes[1], { id: "shared-light", ...SHARED_THEME }); - }), - ), - ); - it.effect("follows the directory rather than the set read at start", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, @@ -118,17 +90,6 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); - it.effect("streams the current set first", () => - withEnvironmentThemes( - { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, - Effect.gen(function* () { - const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; - const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead); - assert.deepEqual(Option.getOrNull(first), [{ id: "nightfall", ...NIGHTFALL_THEME }]); - }), - ), - ); - it.effect("never replays a set older than the snapshot it started from", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, @@ -214,45 +175,3 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); }); - -// Kept outside the it.layer block above because only the top-level `it` exposes -// `live`. -describe("environment theme watching", () => { - it.live("streams a set for every change to the directory", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-theme-watch-" }); - const themesDir = path.join(baseDir, "userdata", "themes"); - yield* fs.makeDirectory(themesDir, { recursive: true }); - - yield* Effect.gen(function* () { - const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; - const seen = yield* Queue.unbounded>(); - yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => - Queue.offer(seen, themes), - ).pipe(Effect.forkScoped); - - assert.deepEqual(yield* Queue.take(seen), []); - - const staging = path.join(baseDir, "staged.json"); - yield* fs.writeFileString(staging, encodeThemeFile(NIGHTFALL_THEME)); - yield* fs.rename(staging, path.join(themesDir, "nightfall.json")); - assert.deepEqual( - (yield* Queue.take(seen)).map((theme) => theme.id), - ["nightfall"], - ); - - yield* fs.remove(path.join(themesDir, "nightfall.json")); - assert.deepEqual(yield* Queue.take(seen), []); - }).pipe( - Effect.provide( - EnvironmentTheme.layer.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), - ), - ), - Effect.timeout("30 seconds"), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e0b3772197cb..256d1b7f2c49 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -7,29 +7,22 @@ import * as NodeChildProcess from "node:child_process"; import * as ConductorWorkspace from "../project/ConductorWorkspace.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; -import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { ChangeRequest, GitActionProgressEvent, - GitManagerServiceError, GitPreparePullRequestThreadInput, ModelSelection, } from "@t3tools/contracts"; @@ -64,8 +57,6 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; -import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; -import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; @@ -754,13 +745,6 @@ function runStackedAction( ); } -function resolvePullRequest( - manager: GitManager.GitManager["Service"], - input: { cwd: string; reference: string }, -) { - return manager.resolvePullRequest(input); -} - function preparePullRequestThread( manager: GitManager.GitManager["Service"], input: GitPreparePullRequestThreadInput, @@ -922,206 +906,6 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { - it.effect("passive worktree status streams do not start remote refreshes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-passive-vcs-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - const worktreeDir = NodePath.join(repoDir, "worktree"); - yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]); - yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]); - - const { manager } = yield* makeManager(); - let remoteReads = 0; - const workflowContext = yield* Layer.build( - GitWorkflowService.layer.pipe( - Layer.provide( - Layer.succeed(GitManager.GitManager, { - ...manager, - remoteStatus: (input, options) => - manager.remoteStatus(input, options).pipe( - Effect.tap(() => - Effect.sync(() => { - remoteReads += 1; - }), - ), - ), - }), - ), - Layer.provide( - VcsDriverRegistry.layer.pipe( - Layer.provide(VcsProjectConfig.layer), - Layer.provide(VcsProcess.layer), - ), - ), - ), - ); - const broadcasterContext = yield* Layer.build( - VcsStatusBroadcaster.layer.pipe( - Layer.provide( - Layer.succeed( - VcsStatusBroadcaster.VcsAutomaticFetchInterval, - Effect.succeed(Duration.seconds(1)), - ), - ), - Layer.provide( - Layer.succeed( - GitWorkflowService.GitWorkflowService, - Context.get(workflowContext, GitWorkflowService.GitWorkflowService), - ), - ), - Layer.provide( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - hasDemand: () => Effect.succeed(true), - shouldRunScopeWork: () => Effect.succeed(true), - }), - ), - ), - ); - const broadcaster = Context.get( - broadcasterContext, - VcsStatusBroadcaster.VcsStatusBroadcaster, - ); - const passiveScope = yield* Scope.make(); - const snapshots = yield* Deferred.make(); - const localUpdated = yield* Deferred.make(); - const remoteUpdated = yield* Deferred.make(); - let snapshotCount = 0; - for (const cwd of [repoDir, worktreeDir]) { - yield* Stream.runForEach( - broadcaster.streamStatus({ cwd, includeRemote: false }), - (event) => { - if ( - cwd === worktreeDir && - event._tag === "localUpdated" && - event.local.hasWorkingTreeChanges - ) { - return Deferred.succeed(localUpdated, undefined); - } - if (cwd === worktreeDir && event._tag === "remoteUpdated") { - expect(event.remote?.hasUpstream).toBe(true); - return Deferred.succeed(remoteUpdated, undefined); - } - if (event._tag !== "snapshot") return Effect.void; - expect(event.local.isRepo).toBe(true); - expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive"); - snapshotCount += 1; - return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void; - }, - ).pipe( - Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)), - Effect.forkIn(passiveScope), - ); - } - yield* Deferred.await(snapshots); - expect(remoteReads).toBe(0); - yield* TestClock.adjust("1 minute"); - expect(remoteReads).toBe(0); - - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n"); - yield* broadcaster.refreshLocalStatus(worktreeDir); - yield* Deferred.await(localUpdated); - expect(remoteReads).toBe(0); - - const activeScope = yield* Scope.make(); - yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe( - Effect.forkIn(activeScope), - ); - yield* Deferred.await(remoteUpdated); - expect(remoteReads).toBe(1); - yield* Scope.close(activeScope, Exit.void); - yield* TestClock.adjust("1 minute"); - expect(remoteReads).toBe(1); - yield* Scope.close(passiveScope, Exit.void); - }), - ); - - it.effect("status includes draft PR metadata when branch already has a draft PR", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseRefName: "main", - headRefName: "feature/status-open-pr", - isDraft: true, - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.isRepo).toBe(true); - expect(status.hasPrimaryRemote).toBe(true); - expect(status.isDefaultRef).toBe(false); - expect(status.refName).toBe("feature/status-open-pr"); - expect(status.pr).toEqual({ - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseRef: "main", - headRef: "feature/status-open-pr", - state: "open", - isDraft: true, - updatedAt: null, - }); - }), - ); - - it.effect("status trims PR metadata returned by gh before publishing it", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 14, - title: " Existing PR title \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", - baseRefName: " main ", - headRefName: "\tfeature/status-trimmed-pr\t", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.pr).toEqual({ - number: 14, - title: "Existing PR title", - url: "https://github.com/pingdotgg/codething-mvp/pull/14", - baseRef: "main", - headRef: "feature/status-trimmed-pr", - state: "open", - updatedAt: null, - }); - }), - ); - it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1224,33 +1008,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status returns an explicit non-repo result for non-git directories", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); - const { manager } = yield* makeManager(); - - const status = yield* manager.status({ cwd }); - - expect(status).toEqual({ - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }); - }), - ); - it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); @@ -1281,160 +1038,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status briefly caches repeated lookups for the same cwd", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); - - const existingPr = { - number: 113, - title: "Cached PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/113", - baseRefName: "main", - headRefName: "feature/status-cache", - }; - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - const second = yield* manager.status({ cwd: repoDir }); - - expect(first.pr?.number).toBe(113); - expect(second.pr?.number).toBe(113); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); - }), - ); - - it.effect("a warm PR cache does not reread repository identity for status", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); - - const gitConfigReads: string[] = []; - const { manager } = yield* makeManager({ gitConfigReads }); - - yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); - gitConfigReads.length = 0; - yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); - - const identityReads = gitConfigReads.filter( - (key) => - key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", - ); - expect(identityReads).toHaveLength(0); - }), - ); - - it.effect("a warm branch PR lookup starts no git process", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - for (const branch of ["feature/warm-pr", "feature/warm-no-pr"]) { - yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); - yield* runGit(repoDir, ["push", "-u", "origin", branch]); - } - const gitCommands: string[] = []; - const { manager, ghCalls } = yield* makeManager({ - gitCommands, - ghScenario: { - prListByHeadSelector: { - "feature/warm-pr": encodeCliJson([ - { - number: 230, - title: "Warm PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/230", - baseRefName: "main", - headRefName: "feature/warm-pr", - state: "OPEN", - updatedAt: "2026-04-07T15:00:00Z", - }, - ]), - }, - }, - }); - const lookupAll = Effect.forEach(["feature/warm-pr", "feature/warm-no-pr"], (branch) => - manager.branchPullRequest({ cwd: repoDir, branch }), - ); - - const cold = yield* lookupAll; - expect(cold.map((pullRequest) => pullRequest?.number ?? null)).toEqual([230, null]); - expect(gitCommands.length).toBeGreaterThan(0); - - gitCommands.length = 0; - const warm = yield* lookupAll; - expect(warm).toEqual(cold); - expect(gitCommands).toEqual([]); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - }), - ); - - it.effect( - "a branch PR lookup sees an upstream change on request, after a T3 git action, or after the TTL", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/moving-upstream"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/moving-upstream"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/heads/feature/other-head"]); - const pullRequest = (number: number, headRefName: string) => - encodeCliJson([ - { - number, - title: headRefName, - url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - updatedAt: "2026-04-07T15:00:00Z", - }, - ]); - const { manager } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "feature/moving-upstream": pullRequest(235, "feature/moving-upstream"), - "feature/other-head": pullRequest(236, "feature/other-head"), - }, - }, - }); - const lookup = manager.branchPullRequest({ - cwd: repoDir, - branch: "feature/moving-upstream", - }); - expect((yield* lookup)?.number).toBe(235); - - yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]); - const reread = yield* manager.branchPullRequest( - { cwd: repoDir, branch: "feature/moving-upstream" }, - { freshGitState: true }, - ); - expect(reread?.number).toBe(236); - - yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/moving-upstream"]); - yield* manager.invalidateStatus(repoDir); - expect((yield* lookup)?.number).toBe(235); - - yield* runGit(repoDir, ["branch", "--set-upstream-to=origin/feature/other-head"]); - yield* TestClock.adjust("5 minutes"); - expect((yield* lookup)?.number).toBe(236); - }), - ); - it.effect("a git read in flight during a T3 git action does not outlive it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1487,159 +1090,58 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("turn-end refresh finds a new PR and keeps known PRs cached", () => + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/turn-refresh", "origin/main"]); - yield* runGit(repoDir, ["push", "origin", "feature/turn-refresh"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); - const { manager, ghCalls } = yield* makeManager({ + const { manager } = yield* makeManager({ ghScenario: { prListSequence: [ - "[]", JSON.stringify([ { - number: 114, - title: "Opened during the turn", - url: "https://github.com/pingdotgg/codething-mvp/pull/114", + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", baseRefName: "main", - headRefName: "feature/turn-refresh", + headRefName: "feature/saved-branch", + headRefOid: "a".repeat(40), + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", }, ]), ], }, }); - expect((yield* manager.remoteStatus({ cwd: repoDir }))?.pr).toBeNull(); - expect( - (yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }))?.pr, - ).toBeNull(); - const refreshed = yield* manager.remoteStatus( - { cwd: repoDir }, - { refreshUpstream: false, refreshMissingPullRequest: true }, - ); - expect(refreshed?.pr?.number).toBe(114); - yield* manager.remoteStatus( - { cwd: repoDir }, - { refreshUpstream: false, refreshMissingPullRequest: true }, - ); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - }), - ); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); - it.effect("turn-end refresh preserves failed PR lookup backoff", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/rate-limited"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - }, + expect(pullRequest).toMatchObject({ + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRef: "main", + headRef: "feature/saved-branch", + headSha: "a".repeat(40), + state: "open", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-03T15:00:00.000Z", }); - yield* manager.remoteStatus({ cwd: repoDir }); - const callsAfterFailure = ghCalls.length; - yield* manager.remoteStatus( - { cwd: repoDir }, - { refreshUpstream: false, refreshMissingPullRequest: true }, - ); - expect(callsAfterFailure).toBeGreaterThan(0); - expect(ghCalls).toHaveLength(callsAfterFailure); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); }), ); - it.effect("status skips the provider lookup for a branch that was never pushed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/never-pushed"]); - - const { manager, ghCalls } = yield* makeManager(); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.refName).toBe("feature/never-pushed"); - expect(status.pr).toBeNull(); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); - }), - ); - - it.effect("branch PR lookup returns null when the repository has no remotes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const { manager, ghCalls } = yield* makeManager(); - - const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); - - expect(pullRequest).toBeNull(); - expect(ghCalls).toHaveLength(0); - }), - ); - - it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 216, - title: "Saved branch PR", - url: "https://github.com/pingdotgg/t3code/pull/216", - baseRefName: "main", - headRefName: "feature/saved-branch", - headRefOid: "a".repeat(40), - state: "OPEN", - updatedAt: "2026-04-03T15:00:00Z", - }, - ]), - ], - }, - }); - - const pullRequest = yield* manager.branchPullRequest({ - cwd: repoDir, - branch: "feature/saved-branch", - }); - - expect(pullRequest).toMatchObject({ - number: 216, - title: "Saved branch PR", - url: "https://github.com/pingdotgg/t3code/pull/216", - baseRef: "main", - headRef: "feature/saved-branch", - headSha: "a".repeat(40), - state: "open", - closedAt: null, - mergedAt: null, - updatedAt: "2026-04-03T15:00:00.000Z", - }); - expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); - }), - ); - - it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2187,173 +1689,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("branch PR lookup shares the status cache for the same repository identity", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 220, - title: "Shared cache PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/220", - baseRefName: "main", - headRefName: "feature/shared-pr-cache", - headRefOid: "a".repeat(40), - state: "MERGED", - updatedAt: "2026-04-07T15:00:00Z", - }, - ]), - encodeCliJson([ - { - number: 221, - title: "New PR on the same branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/221", - baseRefName: "main", - headRefName: "feature/shared-pr-cache", - headRefOid: "b".repeat(40), - state: "OPEN", - updatedAt: "2026-04-08T15:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - const pullRequest = yield* manager.branchPullRequest({ - cwd: repoDir, - branch: "feature/shared-pr-cache", - }); - - expect(status.pr?.state).toBe("merged"); - expect(pullRequest?.state).toBe("merged"); - expect(pullRequest?.headSha).toBe("a".repeat(40)); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); - const refreshed = yield* manager.branchPullRequest( - { cwd: repoDir, branch: "feature/shared-pr-cache" }, - { refresh: true }, - ); - expect(refreshed).toMatchObject({ - number: 221, - state: "open", - headSha: "b".repeat(40), - repositoryKey: "github.com/pingdotgg/codething-mvp", - }); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - }), - ); - - it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { - yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); - yield* runGit(repoDir, ["push", "-u", "origin", branch]); - } - const pullRequest = (number: number, headRefName: string, state: string) => ({ - number, - title: headRefName, - url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, - baseRefName: "main", - headRefName, - state, - updatedAt: "2026-04-07T15:00:00Z", - }); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), - "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), - }, - }, - }); - const lookupAll = Effect.forEach( - ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], - (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), - ); - const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); - - yield* lookupAll; - expect(prListCalls()).toHaveLength(3); - - yield* TestClock.adjust("61 seconds"); - yield* lookupAll; - expect(prListCalls()).toHaveLength(4); - expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); - - yield* TestClock.adjust("238 seconds"); - yield* lookupAll; - expect(prListCalls()).toHaveLength(5); - expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); - - yield* TestClock.adjust("2 seconds"); - yield* lookupAll; - expect(prListCalls()).toHaveLength(7); - expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); - }), - ); - - it.effect("branch PR lookup announces a pull request when it reads it merged", () => - Effect.scoped( - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - for (const branch of ["feature/merges-later", "feature/already-merged"]) { - yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); - yield* runGit(repoDir, ["push", "-u", "origin", branch]); - } - const pullRequest = (number: number, headRefName: string, state: string) => - encodeCliJson([ - { - number, - title: headRefName, - url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, - baseRefName: "main", - headRefName, - state, - updatedAt: "2026-04-07T15:00:00Z", - }, - ]); - const { manager } = yield* makeManager({ - ghScenario: { - prListSequenceByHeadSelector: { - "feature/merges-later": [ - pullRequest(401, "feature/merges-later", "OPEN"), - pullRequest(401, "feature/merges-later", "MERGED"), - ], - "feature/already-merged": [pullRequest(402, "feature/already-merged", "MERGED")], - }, - }, - }); - const changes = yield* manager.subscribePullRequestStateChanges; - const lookup = (branch: string) => manager.branchPullRequest({ cwd: repoDir, branch }); - - yield* lookup("feature/merges-later"); - yield* lookup("feature/already-merged"); - yield* TestClock.adjust("61 seconds"); - expect((yield* lookup("feature/merges-later"))?.state).toBe("merged"); - - const announced = yield* Stream.runCollect(Stream.take(changes, 2)); - expect(announced).toEqual([ - { host: "github.com", repository: "pingdotgg/codething-mvp", number: 402 }, - { host: "github.com", repository: "pingdotgg/codething-mvp", number: 401 }, - ]); - }), - ), - ); - + // regression: #8600 settle threads server-side it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2453,48 +1789,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status still looks up PRs for a branch pushed without --set-upstream", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-upstream"]); - yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-upstream"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 214, - title: "Pushed without upstream", - url: "https://github.com/pingdotgg/t3code/pull/214", - baseRefName: "main", - headRefName: "feature/pushed-no-upstream", - state: "OPEN", - updatedAt: "2026-04-01T15:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.pr?.number).toBe(214); - expect(ghCalls.filter((call) => call.startsWith("pr list ")).length).toBeGreaterThan(0); - }), - ); - - it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { - expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); - expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); - expect(Duration.toMillis(GitManager.prLookupFailureTtl(4))).toBeGreaterThan(120_000); - expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); - }); - it.each([ [ "https://github.example.com/team/repository/pull/42?tab=files", @@ -2724,141 +2018,135 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect( - "status detects cross-repo PRs from the upstream remote URL owner", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - // Origin is the base repository; a lone remote would be the base itself. - yield* runGit(repoDir, [ - "remote", - "add", - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); - yield* runGit(repoDir, ["add", "fork-pr.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:jasonLaster/codething-mvp.git", - forkDir, - ); + it.effect("status detects cross-repo PRs from the upstream remote URL owner", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + // Origin is the base repository; a lone remote would be the base itself. + yield* runGit(repoDir, [ + "remote", + "add", + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@github.com:jasonLaster/codething-mvp.git", + forkDir, + ); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "jasonLaster/codething-mvp", - }, - headRepositoryOwner: { - login: "jasonLaster", - }, + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", }, - ]), - ], - }, - }); + headRepositoryOwner: { + login: "jasonLaster", + }, + }, + ]), + ], + }, + }); - const status = yield* manager.status({ cwd: repoDir }); - expect(status.refName).toBe("t3code/pr-488/statemachine"); - expect(status.pr).toEqual({ - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseRef: "main", - headRef: "statemachine", - state: "open", - updatedAt: "2026-03-10T07:00:00.000Z", - }); - expect(ghCalls).toContain( - "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), - 20_000, + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRef: "main", + headRef: "statemachine", + state: "open", + updatedAt: "2026-03-10T07:00:00.000Z", + }); + expect(ghCalls).toContain( + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), ); - it.effect( - "status preserves a fork PR whose head is named after the default branch", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "fork-seed", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:contributor/codething-mvp.git", - forkDir, - ); + it.effect("status preserves a fork PR whose head is named after the default branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "fork-seed", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-777/main"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/main"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork-seed", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - main: JSON.stringify([ - { - number: 777, - title: "Fork PR from main", - url: "https://github.com/pingdotgg/codething-mvp/pull/777", - baseRefName: "main", - headRefName: "main", - state: "OPEN", - updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "contributor/codething-mvp", - }, - headRepositoryOwner: { - login: "contributor", - }, + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + main: JSON.stringify([ + { + number: 777, + title: "Fork PR from main", + url: "https://github.com/pingdotgg/codething-mvp/pull/777", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "contributor/codething-mvp", }, - ]), - }, + headRepositoryOwner: { + login: "contributor", + }, + }, + ]), }, - }); + }, + }); - const status = yield* manager.status({ cwd: repoDir }); - expect(status.refName).toBe("t3code/pr-777/main"); - expect(status.pr).toEqual({ - number: 777, - title: "Fork PR from main", - url: "https://github.com/pingdotgg/codething-mvp/pull/777", - baseRef: "main", - headRef: "main", - state: "open", - updatedAt: "2026-03-10T07:00:00.000Z", - }); - expect(ghCalls).toContain( - "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), - 20_000, + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("t3code/pr-777/main"); + expect(status.pr).toEqual({ + number: 777, + title: "Fork PR from main", + url: "https://github.com/pingdotgg/codething-mvp/pull/777", + baseRef: "main", + headRef: "main", + state: "open", + updatedAt: "2026-03-10T07:00:00.000Z", + }); + expect(ghCalls).toContain( + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), ); it.effect( @@ -2945,46 +2233,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ), ).toBe(false); }), - 20_000, - ); - - it.effect("status returns merged PR state when latest PR was merged", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 22, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/22", - baseRefName: "main", - headRefName: "feature/status-merged-pr", - state: "MERGED", - mergedAt: "2026-01-30T10:00:00Z", - updatedAt: "2026-01-30T10:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.refName).toBe("feature/status-merged-pr"); - expect(status.pr).toEqual({ - number: 22, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/22", - baseRef: "main", - headRef: "feature/status-merged-pr", - state: "merged", - updatedAt: "2026-01-30T10:00:00.000Z", - }); - }), ); it.effect("status hides merged PRs on the default branch", () => @@ -3145,107 +2393,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("branch PR lookup verifies identity on the fork that holds the own-name ref", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); - yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); - yield* runGit(repoDir, ["checkout", "-b", "feature/fork-settle", "origin/main"]); - yield* runGit(repoDir, ["push", "team/fork", "feature/fork-settle"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "team/fork", - "git@github.com:contributor/codething-mvp.git", - forkDir, - ); - - const { manager } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "feature/fork-settle": JSON.stringify([ - { - number: 91, - title: "Fork PR to settle", - url: "https://github.com/pingdotgg/codething-mvp/pull/91", - baseRefName: "main", - headRefName: "feature/fork-settle", - state: "MERGED", - updatedAt: "2026-05-02T10:00:00Z", - isCrossRepository: true, - headRepository: { nameWithOwner: "contributor/codething-mvp" }, - headRepositoryOwner: { login: "contributor" }, - }, - ]), - }, - }, - }); - - const pullRequest = yield* manager.branchPullRequest({ - cwd: repoDir, - branch: "feature/fork-settle", - }); - - expect(pullRequest).toMatchObject({ - state: "merged", - closedAt: null, - mergedAt: null, - updatedAt: "2026-05-02T10:00:00.000Z", - }); - }), - ); - - it.effect("status keeps an own-name PR when a later lookup fails on a default upstream", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/sticky-plain", "origin/main"]); - yield* runGit(repoDir, ["push", "origin", "feature/sticky-plain"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "feature/sticky-plain": JSON.stringify([ - { - number: 90, - title: "Sticky own-name PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/90", - baseRefName: "main", - headRefName: "feature/sticky-plain", - state: "OPEN", - updatedAt: "2026-05-01T10:00:00Z", - }, - ]), - }, - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - failAfterCalls: 1, - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - expect(first.pr?.number).toBe(90); - - yield* manager.invalidateStatus(repoDir); - const second = yield* manager.status({ cwd: repoDir }); - expect(second.pr?.number).toBe(90); - }), - ); - it.effect("status prefers open PR when merged PR has newer updatedAt", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3294,75 +2441,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status is resilient to gh lookup failures and returns pr null", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.refName).toBe("feature/status-no-gh"); - expect(status.pr).toBeNull(); - }), - ); - - it.effect("status logs actionable provider detail without exposing the upstream cause", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]); - - const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; - const { manager } = yield* makeManager({ - ghScenario: { - failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", - failCause: new Error(upstreamCause), - }, - }); - const logs: Array<{ message: string; annotations: Record }> = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message: String(message), - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - - const status = yield* manager - .status({ cwd: repoDir }) - .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - - expect(status.pr).toBeNull(); - const warning = logs.find((entry) => entry.message.includes("PR lookup failed")); - expect(warning?.annotations).toMatchObject({ - operation: "lookupStatusPr", - branch: "feature/status-rate-limited", - errorTag: "SourceControlProviderError", - provider: SourceControlProviderKind.make("github"), - providerOperation: "listChangeRequests", - errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", - }); - const loggedText = [ - warning?.message ?? "", - ...Object.values(warning?.annotations ?? {}).map(String), - ].join("\n"); - expect(loggedText).not.toContain(upstreamCause); - expect(loggedText).not.toContain("secret-value"); - }), - ); - it.effect("status keeps the last known PR when a later lookup fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3443,129 +2521,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status keeps the last known PR when the branch gains its first upstream", () => + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-sticky-first-push"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - const existingPr = { - number: 215, - title: "Sticky first-push PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/215", - baseRefName: "main", - headRefName: "feature/pr-sticky-first-push", - }; const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr])], - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - failAfterCalls: 1, - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - expect(first.pr?.number).toBe(215); - - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-sticky-first-push"]); - yield* manager.invalidateStatus(repoDir); - - const second = yield* manager.status({ cwd: repoDir }); - expect(second.pr?.number).toBe(215); - }), - ); - - it.effect("status drops the last known PR when the tracked remote is repointed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-repointed"]); - const originalRemoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-repointed"]); - - const existingPr = { - number: 216, - title: "Old remote PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/216", - baseRefName: "main", - headRefName: "feature/pr-repointed", - }; - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr])], - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - failAfterCalls: 1, - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - expect(first.pr?.number).toBe(216); - - const replacementRemoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "replacement", replacementRemoteDir]); - yield* runGit(repoDir, ["push", "replacement", "feature/pr-repointed"]); - yield* runGit(repoDir, ["remote", "set-url", "origin", replacementRemoteDir]); - yield* manager.invalidateStatus(repoDir); - - const second = yield* manager.status({ cwd: repoDir }); - expect(second.pr).toBeNull(); - }), - ); - - it.effect("status keeps the last known PR when the current remote URL can't be resolved", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-config-hiccup"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-config-hiccup"]); - - const existingPr = { - number: 217, - title: "Config hiccup PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/217", - baseRefName: "main", - headRefName: "feature/pr-config-hiccup", - }; - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr])], - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - failAfterCalls: 1, - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - expect(first.pr?.number).toBe(217); - - yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); - yield* manager.invalidateStatus(repoDir); - - const second = yield* manager.status({ cwd: repoDir }); - expect(second.pr?.number).toBe(217); - }), - ); - - it.effect("creates a commit when working tree is dirty", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); - let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - - const { manager } = yield* makeManager({ - serverSettings: { - sourceControlWritingStyle: { - mode: "custom" as const, - customInstructions: "Use a direct tone.", - }, + serverSettings: { + sourceControlWritingStyle: { + mode: "custom" as const, + customInstructions: "Use a direct tone.", + }, }, textGeneration: { generateCommitMessage: (input) => { @@ -3603,39 +2571,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("preserves custom style when instructions are empty", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nworld\n"); - let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - - const { manager } = yield* makeManager({ - serverSettings: { - sourceControlWritingStyle: { - mode: "custom" as const, - customInstructions: "", - }, - }, - textGeneration: { - generateCommitMessage: (input) => { - generatedPolicy = input.policy; - return Effect.succeed({ subject: "Preserve custom style", body: "" }); - }, - }, - }); - yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(generatedPolicy).toEqual({ - kind: "custom", - inferRepositoryConventions: false, - }); - }), - ); - it.effect("falls back when the dedicated source control writer is unavailable", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3769,51 +2704,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("includes local agent instructions when recent history is empty", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* runGit(repoDir, ["init", "--initial-branch=main"]); - yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); - yield* runGit(repoDir, ["config", "user.name", "Test User"]); - const agentInstructions = "Use lowercase source control text."; - const claudeInstructions = "Keep pull request bodies brief."; - NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); - NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); - yield* runGit(repoDir, ["add", "README.md"]); - let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; - - const { manager } = yield* makeManager({ - serverSettings: { - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }, - sourceControlWritingStyle: { - mode: "repo_conventions" as const, - }, - }, - textGeneration: { - generateCommitMessage: (input) => { - generatedPolicy = input.policy; - return Effect.succeed({ subject: "Create initial commit", body: "" }); - }, - }, - }); - yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(generatedPolicy).toEqual({ - kind: "repo_conventions", - commitInstructions: `Follow the repository's established commit message style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, - changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, - inferRepositoryConventions: true, - }); - }), - ); - it.effect.each([undefined, ["README.md"]])( "a failed generation preserves staging changed while generating (paths: %s)", (filePaths) => @@ -3861,56 +2751,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("uses custom commit message when provided", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - // Patch generation would invoke this unavailable converter. A supplied - // message must commit without generating a patch it will not use. - NodeFS.writeFileSync( - NodePath.join(repoDir, ".git/info/attributes"), - "README.md diff=probe\n", - ); - yield* runGit(repoDir, ["config", "diff.probe.textconv", "t3-missing-textconv-for-test"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom\n"); - let generatedCount = 0; - - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "this should not be used", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feat: custom summary line"); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toContain("- details from user"); - }), - ); - it.effect("commits only selected files when filePaths is provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4039,115 +2879,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("featureBranch uses custom commit message and derives branch name", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\ncustom-feature\n"); - let generatedCount = 0; - - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "unused", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/feat-custom-summary-line"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - }), - ); - - it.effect("skips commit when there are no uncommitted changes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - }), - ); - - it.effect("featureBranch returns error when worktree is clean", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const error = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "GitManagerError", - operation: "runFeatureBranchStep", - cwd: repoDir, - }); - expect(error.message).toContain("no changes to commit"); - }), - ); - - it.effect("commits and pushes with upstream auto-setup when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("origin/feature/stacked-flow"); - }), - ); - it.effect( "pushes and creates PR from a no-upstream branch when local commits are ahead of base", () => @@ -4199,60 +2930,11 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("skips push when branch is already up to date", () => + it.effect("pushes existing commits without committing dirty worktree changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pushes existing clean commits without rerunning commit logic", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "push-only.txt"), "push only\n"); - yield* runGit(repoDir, ["add", "push-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "push", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toBe("origin/feature/push-only"); - }), - ); - - it.effect("pushes existing commits without committing dirty worktree changes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-dirty"]); const remoteDir = yield* createBareRemote(); yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); NodeFS.writeFileSync(NodePath.join(repoDir, "push-dirty.txt"), "push dirty\n"); @@ -4287,107 +2969,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("create_pr pushes committed changes while preserving a dirty worktree", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "create-pr-only.txt"), "create pr\n"); - yield* runGit(repoDir, ["add", "create-pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); - const headBefore = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); - NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "uncommitted readme\n"); - NodeFS.writeFileSync(NodePath.join(repoDir, "untracked.txt"), "untracked work\n"); - const statusBefore = (yield* runGit(repoDir, ["status", "--porcelain"])).stdout; - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 303, - title: "Create PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/303", - baseRefName: "main", - headRefName: "feature/create-pr-only", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(303); - expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout).toBe(statusBefore); - expect( - (yield* runGit(remoteDir, [ - "--git-dir=.", - "rev-parse", - "feature/create-pr-only", - ])).stdout.trim(), - ).toBe(headBefore); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/create-pr-only"), - ), - ).toBe(true); - }), - ); - - it.effect("create_pr falls back to main when source control provider detection fails", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "provider-fallback.txt"), "fallback\n"); - yield* runGit(repoDir, ["add", "provider-fallback.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 404, - title: "Provider fallback", - url: "https://github.com/pingdotgg/codething-mvp/pull/404", - baseRefName: "main", - headRefName: "feature/provider-fallback", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(404); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/provider-fallback"), - ), - ).toBe(true); - }), - ); - it.effect("create_pr targets the remote default branch when it is not main", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4452,689 +3033,34 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { JSON.stringify([ { number: 42, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/existing-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(42); - expect(result.toast).toEqual({ - title: "Opened PR #42", - description: "Existing PR", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - }, - }); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - - it.effect( - "returns existing cross-repo PR metadata found under the bare branch name", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - // Origin is the base repository; a lone remote would be the base itself. - yield* runGit(repoDir, [ - "remote", - "add", - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - expect( - ghCalls.some((call) => - call.includes("pr list --head statemachine --state open --limit 100"), - ), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect.each([ - { remoteUrl: "git@github.com:pingdotgg/codething-mvp.git", ghRepo: null, ghResolved: null }, - { - remoteUrl: "git@github.com:pingdotgg/codething-mvp.git", - ghRepo: "https://github.com/pingdotgg/codething-mvp?tab=readme#readme", - ghResolved: null, - }, - { - remoteUrl: "https://github.example.com:8443/pingdotgg/codething-mvp.git", - ghRepo: "https://github.example.com:8443/pingdotgg/codething-mvp", - ghResolved: null, - }, - // The GitHub resolver finds no repository for this GitLab remote, so the remote decides. - { - remoteUrl: "https://gitlab.example.com:8443/pingdotgg/codething-mvp.git", - ghRepo: null, - ghResolved: null, - }, - // With a port on the remote's host the provider ignores gh-resolved and picks the remote. - { - remoteUrl: "https://github.example.com:8443/pingdotgg/codething-mvp.git", - ghRepo: null, - ghResolved: "acme/demo", - }, - { - remoteUrl: "https://github.example.com:8443/pingdotgg/codething-mvp.git", - ghRepo: "base", - ghResolved: "acme/demo", - }, - ] as const)( - "reuses the open same-repository PR when the only remote is not named origin (remote $remoteUrl, GH_REPO $ghRepo, gh-resolved $ghResolved)", - ({ remoteUrl, ghRepo, ghResolved }) => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "dev"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "fork", "dev"]); - yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, remoteDir); - if (ghResolved !== null) { - yield* runGit(repoDir, ["config", "remote.fork.gh-resolved", ghResolved]); - } - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const { manager, ghCalls } = yield* makeManager({ - environment: ghRepo === null ? {} : { GH_REPO: ghRepo }, - ghScenario: { - prListByHeadSelector: { - dev: encodeCliJson([ - { - number: 3, - title: "Dev", - url: "https://github.com/pingdotgg/codething-mvp/pull/3", - baseRefName: "main", - headRefName: "dev", - state: "OPEN", - isCrossRepository: false, - headRepository: { nameWithOwner: "pingdotgg/codething-mvp" }, - headRepositoryOwner: { login: "pingdotgg" }, - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(3); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect( - "creates fork PRs with the owner head when gh's default repository is not the only remote", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "dev"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork", "dev"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "https://github.com/contributor/demo.git", - forkDir, - ); - // What `gh repo set-default acme/demo` records when the default is not a remote. - yield* runGit(repoDir, ["config", "remote.fork.gh-resolved", "acme/demo"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const { manager, ghCalls } = yield* makeManager(); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect( - ghCalls.some( - (call) => call.startsWith("pr create ") && call.includes("--head contributor:dev"), - ), - ).toBe(true); - }), - 12_000, - ); - - it.effect.each([ - { ghRepo: "acme/demo", ghResolved: null, head: "contributor:dev" }, - { ghRepo: "ghe.example.com/contributor/demo", ghResolved: null, head: "contributor:dev" }, - { ghRepo: "github.com/Contributor/demo", ghResolved: null, head: "dev" }, - { - ghRepo: "https://github.com/contributor/demo?tab=readme#readme", - ghResolved: null, - head: "dev", - }, - { ghRepo: "contributor/demo", ghResolved: "acme/demo", head: "dev" }, - { ghRepo: "acme/demo", ghResolved: "base", head: "contributor:dev" }, - // The provider ignores these GH_REPO values, so gh-resolved decides. - { ghRepo: "junk/extra/contributor/demo", ghResolved: "acme/demo", head: "contributor:dev" }, - { ghRepo: "base", ghResolved: "acme/demo", head: "contributor:dev" }, - ] as const)( - "creates the PR with head $head when GH_REPO is $ghRepo, gh-resolved is $ghResolved and the only remote is contributor/demo", - ({ ghRepo, ghResolved, head }) => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "dev"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork", "dev"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "https://github.com/contributor/demo.git", - forkDir, - ); - if (ghResolved !== null) { - yield* runGit(repoDir, ["config", "remote.fork.gh-resolved", ghResolved]); - } - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const { manager, ghCalls } = yield* makeManager({ environment: { GH_REPO: ghRepo } }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => call.startsWith("pr create ") && call.includes(`--head ${head} `)), - ).toBe(true); - }), - 12_000, - ); - - it.effect.each([ - { - remoteUrl: "https://github.example.com:8443/contributor/demo.git", - ghRepo: "https://github.example.com:9443/contributor/demo", - ghResolved: null, - head: "contributor:dev", - }, - { - remoteUrl: "https://github.example.com:8443/contributor/demo.git", - ghRepo: "github.example.com/contributor/demo", - ghResolved: null, - head: "contributor:dev", - }, - { - remoteUrl: "https://github.example.com:8443/contributor/demo.git", - ghRepo: "github.example.com:8443/contributor/demo", - ghResolved: null, - head: "dev", - }, - { - remoteUrl: "https://github.example.com/contributor/demo.git", - ghRepo: "github.example.com:443/contributor/demo", - ghResolved: null, - head: "dev", - }, - // An SSH port is not part of the host the provider uses. - { - remoteUrl: "ssh://git@github.example.com:2222/contributor/demo.git", - ghRepo: "github.example.com/contributor/demo", - ghResolved: null, - head: "dev", - }, - // The provider ignores gh-resolved only when the remote's host carries a port. - { - remoteUrl: "https://github.example.com:8443/contributor/demo.git", - ghRepo: null, - ghResolved: "acme/demo", - head: "dev", - }, - { - remoteUrl: "https://github.example.com/contributor/demo.git", - ghRepo: null, - ghResolved: "acme/demo", - head: "contributor:dev", - }, - { - remoteUrl: "https://github.example.com:443/contributor/demo.git", - ghRepo: null, - ghResolved: "acme/demo", - head: "contributor:dev", - }, - { - remoteUrl: "git@github.com:contributor/demo.git", - ghRepo: null, - ghResolved: "acme/demo", - head: "contributor:dev", - }, - { - remoteUrl: "ssh://git@github.example.com:2222/contributor/demo.git", - ghRepo: null, - ghResolved: "acme/demo", - head: "contributor:dev", - }, - ] as const)( - "creates the PR with head $head when the only remote is $remoteUrl, GH_REPO is $ghRepo and gh-resolved is $ghResolved", - ({ remoteUrl, ghRepo, ghResolved, head }) => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "dev"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork", "dev"]); - yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); - if (ghResolved !== null) { - yield* runGit(repoDir, ["config", "remote.fork.gh-resolved", ghResolved]); - } - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const { manager, ghCalls } = yield* makeManager({ - environment: ghRepo === null ? {} : { GH_REPO: ghRepo }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => call.startsWith("pr create ") && call.includes(`--head ${head} `)), - ).toBe(true); - }), - 12_000, - ); - - it.effect( - "still treats a fork remote as cross-repository with an upstream remote but no origin", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "dev"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork", "dev"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork", - "https://github.com/contributor/demo.git", - forkDir, - ); - const upstreamDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "upstream", upstreamDir]); - yield* runGit(repoDir, ["push", "upstream", "main"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "upstream", - "https://github.com/acme/demo.git", - upstreamDir, - ); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - - const sameRepositoryPr = { - number: 3, - title: "Same-repository decoy", - url: "https://github.com/contributor/demo/pull/3", - baseRefName: "main", - headRefName: "dev", - state: "OPEN", - isCrossRepository: false, - headRepository: { nameWithOwner: "contributor/demo" }, - headRepositoryOwner: { login: "contributor" }, - }; - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - dev: encodeCliJson([ - sameRepositoryPr, - { - ...sameRepositoryPr, - number: 4, - title: "Fork PR", - url: "https://github.com/acme/demo/pull/4", - isCrossRepository: true, - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(4); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect( - "returns the correct existing PR when a slash remote checks out to a synthetic local alias", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - originDir, - ); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "my-org/upstream", - "ssh://git@github.com/pingdotgg/codething-mvp.git", - upstreamDir, - ); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - }, - ]), - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(1618); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, - ); - }), - 20_000, - ); - - it.effect( - "picks the fork PR among same-named branches from other repositories", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - // Origin is the base repository; a lone remote would be the base itself. - yield* runGit(repoDir, [ - "remote", - "add", - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "t3code/pr-142/statemachine": JSON.stringify([]), - statemachine: JSON.stringify([ - { - number: 41, - title: "Unrelated same-repo PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/41", - baseRefName: "main", - headRefName: "statemachine", - }, - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - expect( - ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), - ).toBe(false); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect( - "stops probing head selectors after finding an existing PR", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - // Origin is the base repository; a lone remote would be the base itself. - yield* runGit(repoDir, [ - "remote", - "add", - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* configureVisibleRemoteUrlWithLocalRewrite( - repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - statemachine: JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - "t3code/pr-142/statemachine": JSON.stringify([]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); - expect(openLookupCalls).toHaveLength(1); - expect(openLookupCalls[0]).toContain( - "pr list --head statemachine --state open --limit 100", - ); - }), - 12_000, - ); - - it.effect( - "does not reuse a cross-repo PR when GitHub omits head identity metadata", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - // Origin is the base repository; a lone remote would be the base itself. - yield* runGit(repoDir, [ - "remote", - "add", - "origin", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:octocat/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequenceByHeadSelector: { - statemachine: [ - `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, - `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, - ], - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/existing-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(142); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true); - }), - 20_000, + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(42); + expect(result.toast).toEqual({ + title: "Opened PR #42", + description: "Existing PR", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + }, + }); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), ); it.effect("matches mounted Forgejo heads without confusing forks sharing a branch", () => @@ -5276,83 +3202,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("creates PR when one does not already exist", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.mkdirSync(NodePath.join(repoDir, ".github")); - NodeFS.writeFileSync( - NodePath.join(repoDir, ".github", "pull_request_template.md"), - "## What changed?\n\n## Verification", - ); - yield* runGit(repoDir, ["add", ".github/pull_request_template.md"]); - yield* runGit(repoDir, ["commit", "-m", "Add pull request template"]); - yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); - yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); - let generatedPolicy: TextGeneration.PrContentGenerationInput["policy"] = undefined; - let generatedChangeRequestTemplate: string | undefined; - let generatedThreadContext: string | undefined; - - const { manager, ghCalls } = yield* makeManager({ - serverSettings: { - sourceControlWritingStyle: { - mode: "custom" as const, - customInstructions: "Lead with user impact.", - }, - }, - textGeneration: { - generatePrContent: (input) => { - generatedPolicy = input.policy; - generatedChangeRequestTemplate = input.changeRequestTemplate; - generatedThreadContext = input.threadContext; - return Effect.succeed({ - title: "Add stacked git actions", - body: "## What changed?\nAdded stacked git actions.", - }); - }, - }, - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 88, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/88", - baseRefName: "main", - headRefName: "feature-create-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(88); - expect(generatedPolicy).toMatchObject({ - changeRequestInstructions: "Lead with user impact.", - }); - expect(generatedChangeRequestTemplate).toBe("## What changed?\n\n## Verification"); - expect(generatedThreadContext).toBeUndefined(); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - it.effect("create_pr sends bounded checks to the model and omits rolled-back runs", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -5525,73 +3374,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect.each([true, false])( - "generates GitLab MR content with followChangeRequestTemplates=%s", - (followChangeRequestTemplates) => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const templateDir = NodePath.join(repoDir, ".gitlab", "merge_request_templates"); - NodeFS.mkdirSync(templateDir, { recursive: true }); - NodeFS.writeFileSync( - NodePath.join(templateDir, "dEfAuLt.MD"), - " ## Summary\n\n## Testing\n", - ); - NodeFS.writeFileSync(NodePath.join(templateDir, "Bugfix.md"), "Wrong sibling template"); - yield* runGit(repoDir, ["add", ".gitlab"]); - yield* runGit(repoDir, ["commit", "-m", "Add merge request template"]); - yield* runGit(repoDir, ["checkout", "-b", "feature-gitlab-template"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - NodeFS.writeFileSync(NodePath.join(templateDir, "dEfAuLt.MD"), "Wrong feature template"); - yield* runGit(repoDir, ["add", ".gitlab"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature-gitlab-template"]); - yield* runGit(repoDir, ["config", "branch.feature-gitlab-template.gh-merge-base", "main"]); - NodeFS.writeFileSync( - NodePath.join(templateDir, "dEfAuLt.MD"), - "Wrong uncommitted template", - ); - const gitlab = yield* makeGitLabTemplateProvider(); - const { service: fakeProvider } = createGitHubProviderWithFakeGh({ - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 7, - title: "Follow the GitLab template", - url: "https://gitlab.com/group/project/-/merge_requests/7", - baseRefName: "main", - headRefName: "feature-gitlab-template", - }, - ]), - ], - }); - let generatedChangeRequestTemplate: string | undefined; - - const { manager } = yield* makeManager({ - serverSettings: { sourceControlWritingStyle: { followChangeRequestTemplates } }, - sourceControlProvider: { - ...fakeProvider, - kind: SourceControlProviderKind.make("gitlab"), - readChangeRequestTemplate: gitlab.readChangeRequestTemplate!, - }, - textGeneration: { - generatePrContent: (input) => { - generatedChangeRequestTemplate = input.changeRequestTemplate; - return Effect.succeed({ title: "Follow the GitLab template", body: "Body" }); - }, - }, - }); - const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }); - - expect(result.pr.status).toBe("created"); - expect(generatedChangeRequestTemplate).toBe( - followChangeRequestTemplates ? "## Summary\n\n## Testing" : undefined, - ); - }), - ); - it.effect("ignores unavailable or non-regular GitLab default templates", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -5704,49 +3486,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect.each([ - "release/v2", - "refs/heads/release/v2", - "origin/release/v2", - "deadbeef", - "remote-only", - ])("preserves the configured PR target branch %s", (baseRef) => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const baseBranch = baseRef === "deadbeef" ? baseRef : "release/v2"; - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["checkout", "-b", baseBranch]); - yield* runGit(repoDir, ["push", "-u", "origin", baseBranch]); - yield* runGit(repoDir, ["checkout", "-b", "feature/configured-base"]); - if (baseRef === "remote-only") { - yield* runGit(repoDir, ["branch", "-D", baseBranch]); - } - NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); - yield* runGit(repoDir, ["add", "feature.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/configured-base"]); - yield* runGit(repoDir, [ - "config", - "branch.feature/configured-base.gh-merge-base", - baseRef === "remote-only" ? baseBranch : baseRef, - ]); - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { prListSequence: ["[]", "[]"] }, - }); - - const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }); - - expect(result.pr.status).toBe("created"); - expect( - ghCalls.some((call) => - call.includes(`pr create --base ${baseBranch} --head feature/configured-base`), - ), - ).toBe(true); - }), - ); - it.effect("generates PR content from the created worktree's selected remote base", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -6112,195 +3851,71 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, - "fork-seed", - "git@github.com:octocat/codething-mvp.git", - forkDir, - ); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequenceByHeadSelector: { - statemachine: [ - JSON.stringify([]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), - ).toBe(true); - expect( - ghCalls.some((call) => - call.includes("pr create --base statemachine --head octocat:statemachine"), - ), - ).toBe(false); - }), - ); - - it.effect("rejects push/pr actions from detached HEAD", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); - - const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("detached HEAD"); - }), - ); - - it.effect("surfaces missing gh binary errors", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: - "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("No GitHub credential on the server"); - }), - ); - - it.effect("surfaces gh auth errors with guidance", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("gh auth login"); - }), - ); - - it.effect("resolves pull requests from #number references", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); + "fork-seed", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - pullRequest: { - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/resolve-pr", - state: "open", + prListSequenceByHeadSelector: { + statemachine: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], }, }, }); - const result = yield* resolvePullRequest(manager, { + const result = yield* runStackedAction(manager, { cwd: repoDir, - reference: "#42", + action: "commit_push_pr", }); - expect(result.pullRequest).toEqual({ - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseBranch: "main", - headBranch: "feature/resolve-pr", - state: "open", - }); - expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), + ).toBe(true); + expect( + ghCalls.some((call) => + call.includes("pr create --base statemachine --head octocat:statemachine"), + ), + ).toBe(false); }), ); - it.effect("prepares pull request threads in local mode by checking out the PR branch", () => + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "local.txt"), "local\n"); - yield* runGit(repoDir, ["add", "local.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 64, - title: "Local PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/64", - baseRefName: "main", - headRefName: "feature/pr-local", - state: "open", - }, - }, - }); + yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); - const result = yield* preparePullRequestThread(manager, { + const { manager } = yield* makeManager(); + const errorMessage = yield* runStackedAction(manager, { cwd: repoDir, - reference: "#64", - mode: "local", - }); - - expect(result.branch).toBe("feature/pr-local"); - expect(result.worktreePath).toBeNull(); - const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); - expect(branch).toBe("feature/pr-local"); - expect(ghCalls).toContain("pr checkout 64"); + action: "commit_push", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("detached HEAD"); }), ); @@ -6718,51 +4333,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("prepares pull request threads in worktree mode on the PR head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "worktree.txt"), "worktree\n"); - yield* runGit(repoDir, ["add", "worktree.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 77, - title: "Worktree PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/77", - baseRefName: "main", - headRefName: "feature/pr-worktree", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "77", - mode: "worktree", - }); - - expect(result.branch).toBe("feature/pr-worktree"); - expect(result.worktreePath).not.toBeNull(); - expect(NodeFS.existsSync(result.worktreePath as string)).toBe(true); - const worktreeBranch = (yield* runGit(result.worktreePath as string, [ - "branch", - "--show-current", - ])).stdout.trim(); - expect(worktreeBranch).toBe("feature/pr-worktree"); - }), - ); - it.effect("prepares a worktree from the head branch when the host has no pull ref", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -6860,118 +4430,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("preserves both branch materialization failures when the fallback also fails", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - - const missingForkDir = NodePath.join(repoDir, "missing-fork.git"); - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 93, - title: "Missing fork branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/93", - baseRefName: "main", - headRefName: "feature/missing-fork-branch", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: missingForkDir, - sshUrl: missingForkDir, - }, - }, - }, - }); - - const error = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "93", - mode: "worktree", - }).pipe(Effect.flip); - - if (error._tag !== "GitPullRequestMaterializationError") { - return yield* Effect.die(error); - } - expect(error).toMatchObject({ - cwd: repoDir, - pullRequestNumber: 93, - headRepository: "octocat/codething-mvp", - headBranch: "feature/missing-fork-branch", - localBranch: "t3code/pr-93/feature/missing-fork-branch", - }); - if (!(error.cause instanceof AggregateError)) { - return yield* Effect.die(error.cause); - } - expect(error.cause.errors).toHaveLength(2); - expect(error.cause.errors).toEqual([ - expect.objectContaining({ _tag: "GitCommandError" }), - expect.objectContaining({ _tag: "GitCommandError" }), - ]); - expect(error.cause.cause).toBe(error.cause.errors[0]); - }), - ); - - it.effect("launches setup when creating a new PR worktree", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "setup.txt"), "setup\n"); - yield* runGit(repoDir, ["add", "setup.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 177, - title: "Worktree setup PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/177", - baseRefName: "main", - headRefName: "feature/pr-worktree-setup", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "177", - mode: "worktree", - threadId: asThreadId("thread-pr-setup"), - }); - - expect(result.worktreePath).not.toBeNull(); - expect(setupCalls).toHaveLength(1); - expect(setupCalls[0]).toEqual({ - threadId: "thread-pr-setup", - projectCwd: repoDir, - worktreePath: result.worktreePath as string, - }); - }), - ); - it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -7034,59 +4492,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("preserves fork upstream tracking when preparing a local PR thread", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "local-fork.txt"), "local fork\n"); - yield* runGit(repoDir, ["add", "local-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 82, - title: "Local Fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/82", - baseRefName: "main", - headRefName: "feature/pr-local-fork", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "82", - mode: "local", - }); - - expect(result.worktreePath).toBeNull(); - expect(result.branch).toBe("feature/pr-local-fork"); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("fork-seed/feature/pr-local-fork"); - }), - ); - it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -7104,96 +4509,43 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { "push", "-u", "binbandit-seed", - "fix/git-action-default-without-origin", - ]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 642, - title: "fix: use commit as the default git action without origin", - url: "https://github.com/pingdotgg/t3code/pull/642", - baseRefName: "main", - headRefName: "fix/git-action-default-without-origin", - state: "open", - isCrossRepository: true, - headRepositoryOwnerLogin: "binbandit", - }, - repositoryCloneUrls: { - "binbandit/t3code": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "642", - mode: "local", - }); - - expect(result.branch).toBe("fix/git-action-default-without-origin"); - expect(result.worktreePath).toBeNull(); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("binbandit-seed/fix/git-action-default-without-origin"); - }), - ); - - it.effect("reuses an existing dedicated worktree for the PR head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "existing.txt"), "existing\n"); - yield* runGit(repoDir, ["add", "existing.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); - yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = NodePath.join( - repoDir, - "..", - `pr-existing-${NodePath.basename(repoDir)}`, - ); - yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + "fix/git-action-default-without-origin", + ]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { - number: 78, - title: "Existing worktree PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/78", + number: 642, + title: "fix: use commit as the default git action without origin", + url: "https://github.com/pingdotgg/t3code/pull/642", baseRefName: "main", - headRefName: "feature/pr-existing-worktree", + headRefName: "fix/git-action-default-without-origin", state: "open", + isCrossRepository: true, + headRepositoryOwnerLogin: "binbandit", + }, + repositoryCloneUrls: { + "binbandit/t3code": { + url: forkDir, + sshUrl: forkDir, + }, }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, - reference: "78", - mode: "worktree", - threadId: asThreadId("thread-pr-existing-worktree"), + reference: "642", + mode: "local", }); - expect(result.worktreePath && NodeFS.realpathSync.native(result.worktreePath)).toBe( - NodeFS.realpathSync.native(worktreePath), - ); - expect(result.branch).toBe("feature/pr-existing-worktree"); - expect(setupCalls).toHaveLength(0); - expect(result.isOnPullRequestHead).toBe(false); + expect(result.branch).toBe("fix/git-action-default-without-origin"); + expect(result.worktreePath).toBeNull(); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("binbandit-seed/fix/git-action-default-without-origin"); }), ); @@ -7252,126 +4604,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("runs the setup script when a reused PR worktree moves onto the new head", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-setup"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup\n"); - yield* runGit(repoDir, ["add", "reused-setup.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Reused setup PR branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-setup"]); - yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = NodePath.join( - repoDir, - "..", - `pr-reused-setup-${NodePath.basename(repoDir)}`, - ); - yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-setup"]); - - yield* runGit(repoDir, ["checkout", "-b", "setup-author-push", "feature/pr-reused-setup"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "reused-setup.txt"), "reused setup again\n"); - yield* runGit(repoDir, ["add", "reused-setup.txt"]); - yield* runGit(repoDir, ["commit", "-m", "New reused setup head"]); - yield* runGit(repoDir, ["push", "origin", "setup-author-push:feature/pr-reused-setup"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 85, - title: "Reused setup PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/85", - baseRefName: "main", - headRefName: "feature/pr-reused-setup", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "85", - mode: "worktree", - threadId: asThreadId("thread-pr-reused-setup"), - }); - - expect(setupCalls).toHaveLength(1); - expect(setupCalls[0]).toEqual({ - threadId: "thread-pr-reused-setup", - projectCwd: repoDir, - worktreePath: result.worktreePath as string, - }); - expect(result.isOnPullRequestHead).toBe(true); - }), - ); - - it.effect("leaves the setup script alone when a reused PR worktree is already on the head", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-current"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "reused-current.txt"), "reused current\n"); - yield* runGit(repoDir, ["add", "reused-current.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Reused current PR branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-reused-current"]); - yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = NodePath.join( - repoDir, - "..", - `pr-reused-current-${NodePath.basename(repoDir)}`, - ); - yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-current"]); - const currentHead = (yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim(); - - const setupCalls: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput[] = []; - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 95, - title: "Reused current PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/95", - baseRefName: "main", - headRefName: "feature/pr-reused-current", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "95", - mode: "worktree", - threadId: asThreadId("thread-pr-reused-current"), - }); - - expect(result.isOnPullRequestHead).toBe(true); - expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(currentHead); - expect(setupCalls).toHaveLength(0); - }), - ); - it.effect("resets a clean reused PR worktree onto a force-pushed pull request head", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -7845,126 +5077,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect( - "does not block fork PR worktree prep when the fork head branch collides with root main", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main.txt"), "fork main\n"); - yield* runGit(repoDir, ["add", "fork-main.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); - yield* runGit(repoDir, ["checkout", "main"]); - const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 91, - title: "Fork main PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/91", - baseRefName: "main", - headRefName: "main", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "91", - mode: "worktree", - }); - - expect(result.branch).toBe("t3code/pr-91/main"); - expect(result.worktreePath).not.toBeNull(); - expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); - expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); - expect( - (yield* runGit(result.worktreePath as string, [ - "branch", - "--show-current", - ])).stdout.trim(), - ).toBe("t3code/pr-91/main"); - }), - ); - - it.effect( - "does not overwrite an existing local main branch when preparing a fork PR worktree", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - NodeFS.writeFileSync(NodePath.join(repoDir, "fork-main-second.txt"), "fork main second\n"); - yield* runGit(repoDir, ["add", "fork-main-second.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); - yield* runGit(repoDir, ["checkout", "main"]); - const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); - yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 92, - title: "Fork main overwrite PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/92", - baseRefName: "main", - headRefName: "main", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "92", - mode: "worktree", - }); - - expect(result.branch).toBe("t3code/pr-92/main"); - expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); - expect( - (yield* runGit(result.worktreePath as string, [ - "rev-parse", - "--abbrev-ref", - "@{upstream}", - ])).stdout.trim(), - ).toBe("fork-seed/main"); - }), - ); - it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -8120,124 +5232,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("emits ordered progress events for commit hooks", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "hooked.txt"), "hooked\n"); - NodeFS.writeFileSync( - NodePath.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: start" >&2\nsleep 0.05\necho "hook: end" >&2\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-1", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("created"); - expect(events.map((event) => event.kind)).toContain("action_started"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "phase_started", - phase: "commit", - }), - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: start", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: end", - }), - expect.objectContaining({ - kind: "hook_finished", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "action_finished", - }), - ]), - ); - }), - ); - - it.effect("emits action_failed when a commit hook rejects", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - NodeFS.writeFileSync(NodePath.join(repoDir, "hook-failure.txt"), "broken\n"); - NodeFS.writeFileSync( - NodePath.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const errorMessage = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-2", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("Git command failed in GitVcsDriver.commit.commit"); - expect(errorMessage).not.toContain("hook: fail"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: fail", - }), - expect.objectContaining({ - kind: "action_failed", - phase: "commit", - }), - ]), - ); - }), - ); - it.effect("create_pr preserves dirty work on an already pushed branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 76d4b1e72ce4..333b5a3fe1ac 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -193,7 +193,7 @@ const isSourceControlProviderError = Schema.is(SourceControlProviderError); * into sustained pressure. Backing off per branch keeps the retry rate below * the healthy rate once a branch has failed more than a couple of times. */ -export function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { +function prLookupFailureTtl(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); const backoffMs = Duration.toMillis(PR_LOOKUP_FAILURE_BASE_TTL) * Math.pow(2, exponent); return Duration.min(Duration.millis(backoffMs), PR_LOOKUP_FAILURE_MAX_TTL); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index f046bd325bc4..e46ada6ed8b4 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,10 +1,6 @@ -import { assert, expect, it, vi } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; +import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as FileSystem from "effect/FileSystem"; @@ -17,218 +13,7 @@ import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -function layer(input: { - readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; -}) { - return GitWorkflowService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - detect: input.detect, - }), - ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(ConductorWorkspace.layerNoop), - Layer.provide(Layer.mock(GitManager.GitManager)({})), - ); -} - it.layer(NodeServices.layer)("GitWorkflowService", (it) => { - it.effect("reports a non-Git VCS repository as not a Git repository", () => - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const isRepository = yield* workflow.isRepository("/jj-repo"); - - assert.equal(isRepository, false); - }).pipe( - Effect.provide( - layer({ - detect: () => - Effect.succeed({ - kind: "jj", - repository: { - kind: "jj", - rootPath: "/jj-repo", - metadataPath: "/jj-repo/.jj", - freshness: { - source: "live-local", - observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), - expiresAt: Option.none(), - }, - }, - driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], - }), - }), - ), - ), - ); - - it.effect("returns an empty local status when no VCS repository is detected", () => - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); - - assert.deepStrictEqual(status, { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - }); - }).pipe( - Effect.provide( - layer({ - detect: () => Effect.succeed(null), - }), - ), - ), - ); - - it.effect("returns an empty full status when no VCS repository is detected", () => - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const status = yield* workflow.status({ cwd: "/not-a-repo" }); - - assert.deepStrictEqual(status, { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }); - }).pipe( - Effect.provide( - layer({ - detect: () => Effect.succeed(null), - }), - ), - ), - ); - - it.effect("does not call GitManager status methods when no VCS repository is detected", () => { - const localStatus = vi.fn(); - const remoteStatus = vi.fn(); - const status = vi.fn(); - - const layerTest = GitWorkflowService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - detect: () => Effect.succeed(null), - }), - ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(ConductorWorkspace.layerNoop), - Layer.provide( - Layer.mock(GitManager.GitManager)({ - localStatus, - remoteStatus, - status, - }), - ), - ); - - return Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - yield* workflow.localStatus({ cwd: "/not-a-repo" }); - yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); - yield* workflow.status({ cwd: "/not-a-repo" }); - - assert.equal(localStatus.mock.calls.length, 0); - assert.equal(remoteStatus.mock.calls.length, 0); - assert.equal(status.mock.calls.length, 0); - }).pipe(Effect.provide(layerTest)); - }); - - it.effect("returns an empty ref list when no VCS repository is detected", () => - Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); - - assert.deepStrictEqual(refs, { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }); - }).pipe( - Effect.provide( - layer({ - detect: () => Effect.succeed(null), - }), - ), - ), - ); - - it.effect("structures workflow detection failures without exposing upstream details", () => { - const cause = new VcsRepositoryDetectionError({ - operation: "VcsDriverRegistry.detect", - cwd: "/repo", - detail: "upstream detail must stay in the cause chain", - }); - - return Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "GitManagerError", - operation: "GitWorkflowService.status", - cwd: "/repo", - detail: "Failed to detect a VCS repository for this Git workflow.", - }); - expect(error.message).not.toContain(cause.detail); - }).pipe( - Effect.provide( - layer({ - detect: () => Effect.fail(cause), - }), - ), - ); - }); - - it.effect("structures command detection failures without exposing upstream details", () => { - const cause = new VcsRepositoryDetectionError({ - operation: "VcsDriverRegistry.detect", - cwd: "/repo", - detail: "upstream command detail must stay in the cause chain", - }); - - return Effect.gen(function* () { - const workflow = yield* GitWorkflowService.GitWorkflowService; - const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "GitCommandError", - operation: "GitWorkflowService.listRefs", - command: "vcs-route", - cwd: "/repo", - detail: "Failed to detect a VCS repository for this Git command.", - }); - expect(error.message).not.toContain(cause.detail); - }).pipe( - Effect.provide( - layer({ - detect: () => Effect.fail(cause), - }), - ), - ); - }); - it.effect("finds a name among the refs of every namespace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/git/detachStackFrame.memory.test.ts b/apps/server/src/git/detachStackFrame.memory.test.ts deleted file mode 100644 index 2edc1bd483da..000000000000 --- a/apps/server/src/git/detachStackFrame.memory.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - retention assertions need an isolated process with explicit GC. -import * as NodeChildProcess from "node:child_process"; -import * as NodeURL from "node:url"; -import * as NodeUtil from "node:util"; -import { expect, it } from "vite-plus/test"; -const execFile = NodeUtil.promisify(NodeChildProcess.execFile); -it.each([false, true])( - "a detached cache lookup releases the traced caller snapshot (failure=%s)", - async (failure) => { - const fixture = NodeURL.fileURLToPath( - new URL("./testing/StackRetention.fixture.mjs", import.meta.url), - ); - const { stdout } = await execFile(process.execPath, [ - "--expose-gc", - fixture, - ...(failure ? ["failure"] : []), - ]); - expect(JSON.parse(stdout)).toEqual({ - retained: false, - cachedResult: failure ? "unavailable" : 1, - }); - }, -); diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index 7181549beb60..23af74c8ba22 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -132,19 +132,6 @@ describe("createdPullRequestKey", () => { ), ).toBeNull(); }); - - it("yields nothing when no pull request came out of the action", () => { - expect( - createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project), - ).toBeNull(); - expect( - createdPullRequestKey( - prResult({ status: "created", url: "https://github.com/a/b/pull/1" }), - project, - ), - ).toBeNull(); - expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull(); - }); }); describe("linkCreatedPullRequest", () => { @@ -175,52 +162,6 @@ describe("linkCreatedPullRequest", () => { ]); }), ); - - it.effect("dispatches nothing when the action produced no pull request", () => - Effect.gen(function* () { - const { commands, dispatch } = yield* recordingDispatch(); - const layerDependencies = layerDependenciesFor(dispatch); - yield* linkCreatedPullRequest({ - threadId: THREAD_ID, - result: prResult({ status: "skipped_not_requested" }), - commandId, - }).pipe(Effect.provide(layerDependencies)); - yield* linkCreatedPullRequest({ - threadId: THREAD_ID, - result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }), - commandId, - }).pipe(Effect.provide(layerDependencies)); - - expect(yield* Ref.get(commands)).toEqual([]); - }), - ); - - it.effect("swallows an already-linked rejection and other dispatch failures", () => - Effect.gen(function* () { - const rejecting: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) => - Effect.fail( - new Orchestrator.OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "already linked", - }), - ); - const result = prResult({ - status: "opened_existing", - number: 7, - url: "https://github.com/t3tools/t3code/pull/7", - }); - yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( - Effect.provide(layerDependenciesFor(rejecting)), - ); - yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( - Effect.provide(layerDependenciesFor(() => Effect.die(new Error("engine down")))), - ); - yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( - Effect.provide(layerDependenciesFor(() => Effect.die(new Error("unreachable")), null)), - ); - }), - ); }); it.effect( diff --git a/apps/server/src/git/testing/StackRetention.fixture.mjs b/apps/server/src/git/testing/StackRetention.fixture.mjs deleted file mode 100644 index 94f142e492ef..000000000000 --- a/apps/server/src/git/testing/StackRetention.fixture.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { Cache, Effect } from "effect"; -import { detachStackFrame } from "../detachStackFrame.ts"; -const original = process.argv.includes("original"); -const failure = process.argv.includes("failure"); -const lookup = () => (failure ? Effect.fail("unavailable") : Effect.succeed(1)); -const cache = await Effect.runPromise( - Cache.makeWith(lookup, { capacity: 8, timeToLive: () => "1 hour" }), -); -const get = () => (original ? Cache.get(cache, "key") : detachStackFrame(Cache.get(cache, "key"))); -let reference; -await Effect.runPromise( - Effect.gen(function* () { - const snapshot = { values: Array.from({ length: 250000 }, (_, i) => i) }; - reference = new WeakRef(snapshot); - const request = Effect.fn("StackRetention.request")(function* () { - yield* Effect.exit(get()); - return snapshot.values.length; - }); - yield* request(); - }), -); -for (let i = 0; i < 12; i++) { - await new Promise((resolve) => setImmediate(resolve)); - global.gc(); -} -const retained = reference.deref() !== undefined; -const cachedResult = await Effect.runPromise( - get().pipe(Effect.catch(() => Effect.succeed("unavailable"))), -); -process.stdout.write(JSON.stringify({ retained, cachedResult })); diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 40d877018c4e..c317e6881e65 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -4,12 +4,6 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { - HTML_RENDER_MEASURE_FONTS, - HTML_RENDER_MEASURE_WIDTHS, - htmlRenderTheme, -} from "@t3tools/shared/htmlRender"; -import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; @@ -56,33 +50,6 @@ const layerTest = layerHtmlRender(); const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); -/** - * A stand-in browser that reads T3's first command off its debugging pipe, - * then runs `exit`. Reading it first keeps the pipe from resetting. - */ -const exitingBrowser = (exit: string) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-exiting-browser-" }); - const executable = path.join(directory, "chrome-headless-shell"); - yield* fileSystem.writeFileString( - executable, - [ - `#!${process.execPath}`, - `new (process.getBuiltinModule("node:net").Socket)({ fd: 3 }).on("data", (chunk) => { if (chunk.includes(0)) { ${exit} } });`, - ].join("\n"), - ); - yield* fileSystem.chmod(executable, 0o755); - return executable; - }); - -const previewError = (executable: string) => - Effect.gen(function* () { - const htmlRender = yield* HtmlRender.HtmlRender; - return yield* htmlRender.preview({ html: "

x

" }).pipe(Effect.flip); - }).pipe(Effect.provide(layerHtmlRender(executable))); - describe("HtmlRender", () => { it.effect("inlines local images by absolute path and leaves URLs and relative paths alone", () => Effect.gen(function* () { @@ -320,200 +287,83 @@ describe("HtmlRender", () => { }), ); - describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "when the browser exits before rendering", - () => { - it.live("reports its exit code and keeps what it printed out of the message", () => - Effect.gen(function* () { - const executable = yield* exitingBrowser( - `process.stderr.write("[WARNING:bus.cc] Failed to connect to the bus\\n[FATAL:shared_memory.cc] Check failed: /dev/shm is not writable\\n"); process.exit(3);`, - ); - - const error = yield* previewError(executable); - - expect(error.message).toBe( - "Headless Chrome could not render the page: the browser exited unexpectedly (exit code 3).", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.live("reports the signal that ended it", () => - Effect.gen(function* () { - const executable = yield* exitingBrowser(`process.kill(process.pid, "SIGKILL");`); - - const error = yield* previewError(executable); - - expect(error.message).toBe( - "Headless Chrome could not render the page: the browser exited unexpectedly (signal SIGKILL).", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - }, - ); - - it.live( - "screenshots the page in headless Chrome with the requested theme and every console level", - (ctx) => - Effect.gen(function* () { - const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; - if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); - yield* Effect.gen(function* () { - const htmlRender = yield* HtmlRender.HtmlRender; - const preview = yield* htmlRender.preview({ - html: [ - '
', - '', - "", - '', - "", - ].join(""), - width: 400, - appearance: "dark", - }); - - const png = Buffer.from(preview.png, "base64"); - expect(png.readUInt32BE(0)).toBe(0x89504e47); - expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([400, 300]); - expect(preview).toMatchObject({ - width: 400, - contentHeight: 300, - capturedHeight: 300, - missingImages: ["/nonexistent/t3-missing.png"], - }); - // Headless Chrome prefers light, so a dark background proves the theme fragment applied. - expect(preview.consoleMessages).toEqual( - expect.arrayContaining([ - { level: "log", text: "ready 3" }, - { level: "log", text: "focus 2px -2px 400 400" }, - { level: "info", text: HTML_RENDER_MEASURE_FONTS.sans }, - { - level: "warning", - text: htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark").variables["--background"], - }, - { level: "error", text: "boom" }, - { - level: "error", - text: expect.stringMatching(/^Error: broken chart\n\s+at page\.html:1:\d+$/), - }, - ]), - ); - }).pipe(Effect.provide(layerHtmlRender(executable))); - }), - 30_000, + it.live("keeps every local file but the page itself out of the browser", (ctx) => + Effect.gen(function* () { + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; + if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); + yield* Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const htmlRender = yield* HtmlRender.HtmlRender; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-files-" }); + const secret = path.join(directory, "secret.js"); + yield* fileSystem.writeFileString(secret, 'window.secret = "abc123";'); + const secretUrl = NodeURL.pathToFileURL(secret).href; + + const preview = yield* htmlRender.preview({ + html: [ + ``, + ``, + '', + ].join(""), + }); + + const texts = preview.consoleMessages.map((message) => message.text); + expect(texts).toContain("secret: none"); + expect(texts.join(" ")).not.toContain("abc123"); + }).pipe(Effect.scoped, Effect.provide(layerHtmlRender(executable))); + }), ); - it.live( - "keeps every local file but the page itself out of the browser", - (ctx) => - Effect.gen(function* () { - const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; - if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); - yield* Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const htmlRender = yield* HtmlRender.HtmlRender; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-html-files-" }); - const secret = path.join(directory, "secret.js"); - yield* fileSystem.writeFileString(secret, 'window.secret = "abc123";'); - const secretUrl = NodeURL.pathToFileURL(secret).href; - - const preview = yield* htmlRender.preview({ - html: [ - ``, - ``, - '', - ].join(""), + it.live("keeps the page off this machine's local network", (ctx) => + Effect.gen(function* () { + const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; + if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); + const requests: Array = []; + const server = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const listening = NodeHttp.createServer((request, response) => { + requests.push(`${request.url} ${request.headers["sec-purpose"] ?? ""}`); + response.end("

LOCAL

"); }); - - const texts = preview.consoleMessages.map((message) => message.text); - expect(texts).toContain("secret: none"); - expect(texts.join(" ")).not.toContain("abc123"); - }).pipe(Effect.scoped, Effect.provide(layerHtmlRender(executable))); - }), - 30_000, - ); - - it.live( - "keeps the page off this machine's local network", - (ctx) => - Effect.gen(function* () { - const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; - if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); - const requests: Array = []; - const server = yield* Effect.acquireRelease( - Effect.callback((resume) => { - const listening = NodeHttp.createServer((request, response) => { - requests.push(`${request.url} ${request.headers["sec-purpose"] ?? ""}`); - response.end("

LOCAL

"); - }); - listening.listen(0, "127.0.0.1", () => resume(Effect.succeed(listening))); + listening.listen(0, "127.0.0.1", () => resume(Effect.succeed(listening))); + }), + (listening) => + Effect.callback((resume) => { + listening.close(() => resume(Effect.void)); }), - (listening) => - Effect.callback((resume) => { - listening.close(() => resume(Effect.void)); - }), - ); - const datagrams: Array = []; - const udp = yield* Effect.acquireRelease( - Effect.callback((resume) => { - const socket = NodeDgram.createSocket("udp4"); - socket.on("message", (message) => datagrams.push(message.toString("hex"))); - socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket))); + ); + const datagrams: Array = []; + const udp = yield* Effect.acquireRelease( + Effect.callback((resume) => { + const socket = NodeDgram.createSocket("udp4"); + socket.on("message", (message) => datagrams.push(message.toString("hex"))); + socket.bind(0, "127.0.0.1", () => resume(Effect.succeed(socket))); + }), + (socket) => + Effect.callback((resume) => { + socket.close(() => resume(Effect.void)); }), - (socket) => - Effect.callback((resume) => { - socket.close(() => resume(Effect.void)); - }), - ); - const address = server.address(); - const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`; - const stun = `stun:127.0.0.1:${udp.address().port}`; - yield* Effect.gen(function* () { - const htmlRender = yield* HtmlRender.HtmlRender; - yield* htmlRender.preview({ - html: [ - ``, - ``, - ``, - ].join(""), - }); - }).pipe(Effect.provide(layerHtmlRender(executable))); - expect(requests).toEqual([]); - expect(datagrams).toEqual([]); - }).pipe(Effect.scoped), - 30_000, - ); - - it.live( - "measures a published page at every client width with a fresh load each", - (ctx) => - Effect.gen(function* () { - const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; - if (!executable) return ctx.skip(`Set ${TEST_BROWSER_ENV} to run this test.`); - yield* Effect.gen(function* () { - const htmlRender = yield* HtmlRender.HtmlRender; - const reference = yield* htmlRender.publish({ - threadId: ThreadId.make("thread-html-measure"), - html: '
', - title: "Chart", - height: 600, - }); - - expect(reference.heights).toEqual( - HTML_RENDER_MEASURE_WIDTHS.map((width) => [width, Math.ceil(width / 2)]), - ); - }).pipe(Effect.provide(layerHtmlRender(executable))); - }), - 30_000, + ); + const address = server.address(); + const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : 0}`; + const stun = `stun:127.0.0.1:${udp.address().port}`; + yield* Effect.gen(function* () { + const htmlRender = yield* HtmlRender.HtmlRender; + yield* htmlRender.preview({ + html: [ + ``, + ``, + ``, + ].join(""), + }); + }).pipe(Effect.provide(layerHtmlRender(executable))); + expect(requests).toEqual([]); + expect(datagrams).toEqual([]); + }).pipe(Effect.scoped), ); }); diff --git a/apps/server/src/htmlRender/publicProxy.test.ts b/apps/server/src/htmlRender/publicProxy.test.ts index 15c1e7f369ad..12d0dd65ad34 100644 --- a/apps/server/src/htmlRender/publicProxy.test.ts +++ b/apps/server/src/htmlRender/publicProxy.test.ts @@ -128,30 +128,6 @@ describe("publicProxy", () => { }).pipe(Effect.scoped), ); - it.effect("refuses a request with a wrong version byte", () => - Effect.gen(function* () { - const port = yield* publicProxy; - const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 443), 4); - socket.destroy(); - expect(code).toBe(7); - }).pipe(Effect.scoped), - ); - - it.effect("fails instead of crashing when it cannot listen", () => - Effect.gen(function* () { - const exhausted = Object.assign(new Error("too many open files"), { code: "EMFILE" }); - const listen = vi.spyOn(NodeNet.Server.prototype, "listen").mockImplementationOnce(function ( - this: NodeNet.Server, - ) { - process.nextTick(() => this.emit("error", exhausted)); - return this; - }); - const error = yield* publicProxy.pipe(Effect.flip, Effect.scoped); - listen.mockRestore(); - expect(error).toBe(exhausted); - }), - ); - it.effect("closes every connection when its scope closes", () => Effect.gen(function* () { const socket = yield* Effect.scoped( diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index ddb4b25d707d..48c75a124035 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -13,7 +13,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; -import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; import { openMediaFile } from "./assets/MediaFile.ts"; @@ -27,14 +26,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; import * as ServerConfig from "./config.ts"; -import { - assetResponseHeaders, - assetFileResponse, - downloadContentDisposition, - isLoopbackHostname, - resolveDevRedirectUrl, - withUntracedRequests, -} from "./http.ts"; +import { assetResponseHeaders, assetFileResponse, downloadContentDisposition } from "./http.ts"; import * as ServerHttp from "./http.ts"; describe("MCP app asset responses", () => { @@ -101,45 +93,6 @@ describe("MCP app asset responses", () => { ); }); -describe("untraced requests", () => { - it.effect("drops the HTTP server span for browser trace exports, query string included", () => { - const spanNames: Array = []; - return Effect.gen(function* () { - const layerRoutes = Layer.effectDiscard( - Effect.gen(function* () { - const router = yield* HttpRouter.HttpRouter; - yield* router.add("POST", "/api/observability/v1/traces", HttpServerResponse.empty()); - yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); - }), - ); - const services = yield* Layer.build( - withUntracedRequests(HttpRouter.serve(layerRoutes, { disableListenLog: true })).pipe( - Layer.provideMerge(NodeHttpServer.layerTest), - ), - ); - const client = Context.get(services, HttpClient.HttpClient); - - yield* client.post("/api/observability/v1/traces"); - yield* client.post("/api/observability/v1/traces?x=1"); - expect(spanNames).toEqual([]); - - yield* client.get("/api/environment"); - expect(spanNames).toEqual(["GET"]); - }).pipe( - Effect.scoped, - Effect.provideService( - Tracer.Tracer, - Tracer.make({ - span: (options) => { - if (options.kind === "server") spanNames.push(options.name); - return new Tracer.NativeSpan(options); - }, - }), - ), - ); - }); -}); - describe("browser API CORS", () => { it("accepts protocol negotiation with authenticated browser headers", async () => { const layerRoute = Layer.effectDiscard( @@ -309,79 +262,6 @@ it.layer( }), ); - it.effect("returns 500 for static file open errors other than NotFound", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-open-error-" }); - const filePath = path.join(staticDir, "index.html"); - yield* fs.writeFileString(filePath, "readable"); - const fileSystem = FileSystem.FileSystem.of({ - ...fs, - open: (candidate, options) => - candidate === filePath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "open", - pathOrDescriptor: candidate, - }), - ) - : fs.open(candidate, options), - }); - const request = yield* makeStaticRequest(staticDir).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - - const response = yield* request("/"); - expect(response.status).toBe(500); - expect(yield* response.text).toBe("Internal Server Error"); - }), - ); - - it.effect("returns 500 when a static fallback read fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-read-error-" }); - const filePath = path.join(staticDir, "index.html"); - yield* fs.writeFileString(filePath, "unreadable"); - const fileSystem = FileSystem.FileSystem.of({ - ...fs, - open: (candidate, options) => - candidate === filePath - ? Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "open", - pathOrDescriptor: candidate, - }), - ) - : fs.open(candidate, options), - readFile: (candidate) => - candidate === filePath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: candidate, - }), - ) - : fs.readFile(candidate), - }); - const request = yield* makeStaticRequest(staticDir).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); - - const response = yield* request("/"); - expect(response.status).toBe(500); - expect(yield* response.text).toBe("Internal Server Error"); - }), - ); - it.effect("serves replacement bytes with matching static fallback length and validators", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -642,46 +522,6 @@ describe("video asset byte ranges", () => { }).pipe(Effect.provide(layerFileResponse)), ); - it.effect("streams guarded file ranges, including suffixes and conditional requests", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); - const filePath = path.join(directory, "clip.mp4"); - yield* fs.writeFileString(filePath, "0123456789"); - const canonicalPath = yield* fs.realPath(filePath); - for (const [range, ifRange, expected, status, contentRange] of [ - [undefined, undefined, "0123456789", 200, null], - ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], - ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], - ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], - ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], - ["bytes=10-", undefined, "", 416, "bytes */10"], - ["bytes=0-1", '"old-etag"', "0123456789", 200, null], - ["bytes=0-1", "", "0123456789", 200, null], - ] as const) { - const file = yield* openMediaFile(canonicalPath); - if (!file) throw new Error("Expected an opened media file"); - const response = HttpServerResponse.toWeb( - yield* assetFileResponse( - { path: canonicalPath, file, mimeType: "video/mp4" }, - range, - ifRange, - ), - ); - expect(response.status).toBe(status); - expect(response.headers.get("accept-ranges")).toBe("bytes"); - expect(response.headers.get("content-range")).toBe(contentRange); - expect(response.headers.get("cache-control")).toBe("private, no-store"); - expect(response.headers.get("etag")).toBeNull(); - expect(response.headers.get("last-modified")).toBeNull(); - if (status !== 416) - expect(response.headers.get("content-length")).toBe(String(expected.length)); - expect(yield* Effect.promise(() => response.text())).toBe(expected); - } - }).pipe(Effect.provide(layerFileResponse)), - ); - it.effect("keeps attachment media out of the cache once its signed URL expires", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -853,30 +693,6 @@ describe("video asset byte ranges", () => { ); }); -describe("http dev routing", () => { - it("treats localhost and loopback addresses as local", () => { - expect(isLoopbackHostname("127.0.0.1")).toBe(true); - expect(isLoopbackHostname("localhost")).toBe(true); - expect(isLoopbackHostname("::1")).toBe(true); - expect(isLoopbackHostname("[::1]")).toBe(true); - }); - - it("does not treat LAN addresses as local", () => { - expect(isLoopbackHostname("192.168.86.35")).toBe(false); - expect(isLoopbackHostname("10.0.0.24")).toBe(false); - expect(isLoopbackHostname("example.local")).toBe(false); - }); - - it("preserves path and query when redirecting to the dev server", () => { - const devUrl = new URL("http://127.0.0.1:5173/"); - const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); - - expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( - "http://127.0.0.1:5173/pair?token=test-token", - ); - }); -}); - describe("assetResponseHeaders", () => { it("sandboxes SVG assets", () => { expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({ @@ -887,25 +703,6 @@ describe("assetResponseHeaders", () => { "Content-Security-Policy", ); }); - - it("does not apply document policy to raster images", () => { - expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({ - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }); - }); - - it("serves inline videos with their declared mime type", () => { - expect( - assetResponseHeaders("/attachments/demo.bin", { - mimeType: 'video/mp4; codecs="avc1.42E01E"', - }), - ).toEqual({ - "Cache-Control": "private, max-age=3600", - "Content-Type": "video/mp4", - "X-Content-Type-Options": "nosniff", - }); - }); it("serves inline attachment documents with their declared mime type", () => { expect( assetResponseHeaders("/attachments/upload.bin", { mimeType: "application/pdf" }), @@ -919,14 +716,6 @@ describe("assetResponseHeaders", () => { "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", }); }); - it("serves HTML assets as utf-8 inside a sandboxed origin", () => { - for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { - expect(assetResponseHeaders(path)).toMatchObject({ - "Content-Type": "text/html; charset=utf-8", - "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads", - }); - } - }); it("downloads uploaded documents without executing their content", () => { expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({ @@ -968,18 +757,6 @@ describe("assetResponseHeaders", () => { ).toHaveProperty("Content-Type", "application/octet-stream"); } }); - - it("preserves official Office Open XML mime types", () => { - for (const mimeType of [ - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ]) { - expect( - assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }), - ).toHaveProperty("Content-Type", mimeType); - } - }); }); describe("downloadContentDisposition", () => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e74cf3c00468..ec6da0f9f987 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -278,7 +278,7 @@ export const layerBrowserApiCors = Layer.unwrap( }), ); -export function isLoopbackHostname(hostname: string): boolean { +function isLoopbackHostname(hostname: string): boolean { const normalizedHostname = hostname .trim() .toLowerCase() @@ -286,7 +286,7 @@ export function isLoopbackHostname(hostname: string): boolean { return LOOPBACK_HOSTNAMES.has(normalizedHostname); } -export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { +function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { const redirectUrl = new URL(devUrl.toString()); redirectUrl.pathname = requestUrl.pathname; redirectUrl.search = requestUrl.search; diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 89f509a9f033..ae23fc37a08e 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -125,26 +125,6 @@ describe("guardHttpResponseWriteErrors", () => { }); await expect(fetchStatus(port, "/")).resolves.toBe(200); }); - - it("arms every response with an error listener without disturbing normal traffic", async () => { - const writeErrors: unknown[] = []; - let responseErrorListeners = -1; - const server = guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => { - writeErrors.push(error); - }); - - server.on("request", (_request, response) => { - responseErrorListeners = response.listenerCount("error"); - response.writeHead(200, { "content-type": "text/plain" }); - response.end("ok"); - }); - - const port = await listen(server); - - await expect(fetchStatus(port, "/")).resolves.toBe(200); - expect(responseErrorListeners).toBeGreaterThan(0); - expect(writeErrors).toEqual([]); - }); }); describe("rejectRequestsUntilServing", () => { @@ -183,5 +163,5 @@ describe("rejectRequestsUntilServing", () => { expect(servingUpgrade.statusCode).toBe(101); expect(servingUpgrade.headers["retry-after"]).toBeUndefined(); expect(writeErrors).toEqual([]); - }, 5_000); + }); }); diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index f9c83400edbc..5d427b957265 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -3,13 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; describe("imageMime", () => { - it("parses base64 data URL with mime type", () => { - expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=")).toEqual({ - mimeType: "image/png", - base64: "SGVsbG8=", - }); - }); - it("parses base64 data URL with mime parameters", () => { expect(parseBase64DataUrl("data:image/png;charset=utf-8;base64,SGVsbG8=")).toEqual({ mimeType: "image/png", @@ -17,14 +10,6 @@ describe("imageMime", () => { }); }); - it("rejects non-base64 data URL", () => { - expect(parseBase64DataUrl("data:image/png;charset=utf-8,hello")).toBeNull(); - }); - - it("rejects missing mime type", () => { - expect(parseBase64DataUrl("data:;base64,SGVsbG8=")).toBeNull(); - }); - it("parses base64 data URL with spaces in payload", () => { expect(parseBase64DataUrl("data:image/png;base64,SGVs bG8=\n")).toEqual({ mimeType: "image/png", @@ -37,6 +22,7 @@ describe("imageMime", () => { expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); }); + // regression: #3952 image upload crashed dispatchCommand with a stack overflow it("rejects structurally malformed base64", () => { expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); @@ -67,22 +53,6 @@ describe("imageMime", () => { }); }); - it("parses a multi-megabyte payload from a deep call stack", () => { - const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; - const atDepth = (depth: number): ReturnType => - depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); - const findMaxDepth = (depth: number): number => { - try { - return findMaxDepth(depth + 1); - } catch { - return depth; - } - }; - const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); - expect(result?.mimeType).toBe("image/png"); - expect(result?.base64.length).toBe(14_000_000); - }); - it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e2823551d995..80d4a52625ed 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -2,7 +2,6 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -12,17 +11,10 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; import { KeybindingsConfigError, MAX_KEYBINDINGS_COUNT } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const encodeKeybindingsConfigJson = Schema.encodeEffect(KeybindingsConfigJson); const decodeKeybindingsConfigJson = Schema.decodeUnknownEffect(KeybindingsConfigJson); -const encodeResolvedKeybindingFromConfig = Schema.encodeEffect( - Keybindings.ResolvedKeybindingFromConfig, -); -const decodeResolvedKeybindingFromConfigExit = Schema.decodeUnknownExit( - Keybindings.ResolvedKeybindingFromConfig, -); const layerKeybindings = () => { return Keybindings.layer.pipe( Layer.provideMerge( @@ -109,25 +101,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }), ); - it.effect("encodes resolved plus-key shortcuts", () => - Effect.gen(function* () { - const encoded = yield* encodeResolvedKeybindingFromConfig({ - command: "terminal.toggle", - shortcut: { - key: "+", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - }); - - assert.equal(encoded.key, "mod++"); - assert.equal(encoded.command, "terminal.toggle"); - }), - ); - it.effect("rejects invalid rules", () => Effect.sync(() => { assert.isNull( @@ -155,23 +128,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }), ); - it.effect("formats invalid resolved keybinding rules with the custom message", () => - Effect.sync(() => { - const result = decodeResolvedKeybindingFromConfigExit({ - key: "mod+shift+d+o", - command: "terminal.new", - }); - - if (result._tag !== "Failure") { - assert.fail("Expected invalid keybinding decode to fail"); - } - - const detail = Cause.pretty(result.cause); - assert.isTrue(detail.includes("Invalid keybinding rule")); - assert.isFalse(detail.includes("Invalid data")); - }), - ); - it.effect("bootstraps default keybindings when config file is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -396,32 +352,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); }); - it.effect("upserts custom keybindings to configured path", () => - Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, - ]); - - const resolved = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - return yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - }); - - const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - const persistedView = persisted.map(({ key, command }) => ({ key, command })); - - assert.deepEqual(persistedView, [ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+r", command: "script.run-tests.run" }, - ]); - assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); - }).pipe(Effect.provide(layerKeybindings())), - ); - it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; @@ -533,87 +463,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(layerKeybindings())), ); - it.effect("reports non-array config parse errors without duplicate prefix", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - yield* fs.writeFileString( - keybindingsConfigPath, - '{"key":"mod+j","command":"terminal.toggle"}', - ); - - const firstResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - return yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - }).pipe(toDetailResult); - assertFailure(firstResult, "expected JSON array"); - - const secondResult = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - return yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - }).pipe(toDetailResult); - assertFailure(secondResult, "expected JSON array"); - }).pipe(Effect.provide(layerKeybindings())), - ); - - // chmod cannot make a directory unwritable on Windows, so the write succeeds. - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "fails when config directory is not writable", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - const { dirname } = yield* Path.Path; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, - ]); - yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); - - const result = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - return yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - }).pipe(toDetailResult); - assertFailure(result, "failed to write keybindings config"); - - yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); - - const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - const persistedView = persisted.map(({ key, command }) => ({ key, command })); - assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); - }).pipe(Effect.provide(layerKeybindings())), - ); - - it.effect("caches loaded resolved config across repeated reads", () => - Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, - ]); - - const [first, second] = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - const firstLoad = (yield* keybindings.loadConfigState).keybindings; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+shift+y", command: "script.run-tests.run" }, - ]); - const secondLoad = (yield* keybindings.loadConfigState).keybindings; - return [firstLoad, secondLoad] as const; - }); - - assert.deepEqual(first, second); - assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); - }).pipe(Effect.provide(layerKeybindings())), - ); - it.effect("retries a failed config read instead of keeping the failure", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -635,28 +484,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(layerKeybindings())), ); - it.effect("updates cached resolved config after upsert", () => - Effect.gen(function* () { - const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - yield* writeKeybindingsConfig(keybindingsConfigPath, [ - { key: "mod+j", command: "terminal.toggle" }, - ]); - - const loadedAfterUpsert = yield* Effect.gen(function* () { - const keybindings = yield* Keybindings.Keybindings; - yield* keybindings.loadConfigState; - yield* keybindings.upsertKeybindingRule({ - key: "mod+shift+r", - command: "script.run-tests.run", - }); - return (yield* keybindings.loadConfigState).keybindings; - }); - - assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); - assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); - }).pipe(Effect.provide(layerKeybindings())), - ); - it.effect("serializes concurrent upserts to avoid lost updates", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/launcherWatch.test.ts b/apps/server/src/launcherWatch.test.ts index 43c3e381f6fb..1482804c61e0 100644 --- a/apps/server/src/launcherWatch.test.ts +++ b/apps/server/src/launcherWatch.test.ts @@ -1,7 +1,6 @@ -import { assert, it } from "@effect/vitest"; +import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { awaitLauncherLoss } from "./launcherWatch.ts"; @@ -15,21 +14,6 @@ const watch = (getParentPid: () => number, platform: NodeJS.Platform, startPid = Effect.provideService(HostProcess.Platform, platform), ); -it.effect("completes when the parent changes, including to a subreaper", () => - Effect.gen(function* () { - let parentPid = launcherPid; - const fiber = yield* Effect.forkChild(watch(() => parentPid, "linux")); - yield* Effect.yieldNow; - - yield* TestClock.adjust("10 seconds"); - assert.isUndefined(fiber.pollUnsafe(), "still watching while the launcher is alive"); - - parentPid = subreaperPid; - yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(fiber); - }), -); - it.effect("notices a launcher that was already gone when the watch began", () => Effect.gen(function* () { const fiber = yield* Effect.forkChild(watch(() => subreaperPid, "linux")); @@ -37,29 +21,3 @@ it.effect("notices a launcher that was already gone when the watch began", () => yield* Fiber.join(fiber); }), ); - -it.effect("does not watch a backend that already starts under init", () => - Effect.gen(function* () { - let parentPid = 1; - const fiber = yield* Effect.forkChild(watch(() => parentPid, "linux", 1)); - yield* Effect.yieldNow; - - parentPid = subreaperPid; - yield* TestClock.adjust("1 minute"); - assert.isUndefined(fiber.pollUnsafe()); - yield* Fiber.interrupt(fiber); - }), -); - -it.effect("does not watch on Windows, where the original parent id outlives the parent", () => - Effect.gen(function* () { - let parentPid = launcherPid; - const fiber = yield* Effect.forkChild(watch(() => parentPid, "win32")); - yield* Effect.yieldNow; - - parentPid = subreaperPid; - yield* TestClock.adjust("1 minute"); - assert.isUndefined(fiber.pollUnsafe()); - yield* Fiber.interrupt(fiber); - }), -); diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 5734d613bf52..0368c8971420 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; -import { AcpMcpBridgeError, callAcpMcpTool, runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; +import { runAcpMcpStdioBridge } from "./AcpMcpStdioBridge.ts"; function makeHarness(responder: (request: Request) => Promise | Response) { const input = new NodeStream.PassThrough(); @@ -30,66 +30,6 @@ function makeHarness(responder: (request: Request) => Promise | Respon } describe("AcpMcpStdioBridge", () => { - it.effect("preserves the original transport failure as the typed error cause", () => - Effect.gen(function* () { - const source = new Error("fetch failed"); - const failure = yield* callAcpMcpTool({ - endpoint: "http://127.0.0.1:1/mcp", - authorization: "Bearer bridge-test", - tool: "orchestrator_capabilities", - arguments: {}, - fetchImplementation: () => Promise.reject(source), - }).pipe(Effect.asVoid, Effect.flip); - - expect(failure).toBeInstanceOf(AcpMcpBridgeError); - expect(failure.cause).toBe(source); - }), - ); - - it.effect("calls one tool through a fresh authenticated MCP session", () => - Effect.gen(function* () { - const requests: Array<{ readonly body: unknown; readonly headers: Headers }> = []; - const result = yield* callAcpMcpTool({ - endpoint: "http://127.0.0.1:1/mcp", - authorization: "Bearer bridge-test", - tool: "orchestrator_capabilities", - arguments: {}, - fetchImplementation: async (_url, init) => { - const headers = new Headers(init?.headers); - const body: unknown = JSON.parse(String(init?.body ?? "{}")); - requests.push({ body, headers }); - const request = body as { readonly id?: unknown; readonly method?: unknown }; - if (request.method === "notifications/initialized") { - return new Response(null, { status: 202 }); - } - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: request.id, - result: - request.method === "initialize" - ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: {} } - : { structuredContent: { providers: ["codex"] } }, - }), - { - headers: { "content-type": "application/json", "mcp-session-id": "session-42" }, - }, - ); - }, - }); - - expect(result).toEqual({ structuredContent: { providers: ["codex"] } }); - expect(requests).toHaveLength(3); - expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); - expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); - expect(requests[2]?.headers.get("mcp-protocol-version")).toBe("2025-06-18"); - expect(requests[2]?.body).toMatchObject({ - method: "tools/call", - params: { name: "orchestrator_capabilities", arguments: {} }, - }); - }), - ); - it.effect("forwards requests and replays the session id and protocol version", () => Effect.gen(function* () { const { input, written, requests, bridge } = makeHarness((request) => { diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index f61d20b2cfd2..2db36ce5abc2 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -6,6 +6,7 @@ import * as NodeReadline from "node:readline"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; @@ -53,96 +54,125 @@ function protocolVersionOf(entry: unknown): string | null { return typeof version === "string" && version.length > 0 ? version : null; } -export interface AcpMcpToolCallOptions { +export interface McpToolSessionOptions { readonly endpoint: string; readonly authorization: string; - readonly tool: string; - readonly arguments: Readonly>; + readonly clientName: string; readonly fetchImplementation?: (url: string, init?: RequestInit) => Promise; } +export interface McpToolSession { + /** Resolves to the JSON-RPC `result` of `tools/call`, a `CallToolResult`. */ + readonly call: ( + tool: string, + args: Readonly>, + ) => Effect.Effect; +} + /** - * This is the terminal fallback for ACP agents that accept `mcpServers` in - * `session/new` but fail to expose those tools to their model. Compliant ACP - * agents continue to use the stdio bridge above. + * One initialized MCP session over T3 Code's HTTP endpoint, terminated when + * the scope closes. Used by the ACP terminal fallback and `t3 thread`. */ -export function callAcpMcpTool( - options: AcpMcpToolCallOptions, -): Effect.Effect { +export const mcpToolSession = ( + options: McpToolSessionOptions, +): Effect.Effect => { const fetchImplementation = options.fetchImplementation ?? fetch; - return Effect.gen(function* () { - let sessionId: string | null = null; - let protocolVersion: string | null = null; + let sessionId: string | null = null; + let protocolVersion: string | null = null; + let nextId = 0; - const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( - message: unknown, - ): Effect.fn.Return, AcpMcpBridgeError> { - const response = yield* Effect.tryPromise({ - try: () => - fetchImplementation(options.endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - authorization: options.authorization, - ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), - ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), - }, - body: JSON.stringify(message), - }), - catch: bridgeError, - }); - sessionId = response.headers.get("mcp-session-id") ?? sessionId; - if (!response.ok) { - yield* discardResponseBody(response); - return yield* Effect.fail( - new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), - ); - } - const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); - for (const payload of payloads) { - protocolVersion = protocolVersionOf(payload) ?? protocolVersion; - } - return payloads; - }); + const headers = () => ({ + accept: "application/json, text/event-stream", + authorization: options.authorization, + ...(sessionId === null ? {} : { "mcp-session-id": sessionId }), + ...(protocolVersion === null ? {} : { "mcp-protocol-version": protocolVersion }), + }); - const initializeId = "t3-acp-cli-initialize"; - const initialized = yield* send({ - jsonrpc: "2.0", - id: initializeId, - method: "initialize", - params: { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "t3-code-acp-cli", version: "0.0.0" }, - }, + const send = Effect.fn("AcpMcpStdioBridge.send")(function* ( + message: unknown, + ): Effect.fn.Return, AcpMcpBridgeError> { + const response = yield* Effect.tryPromise({ + try: () => + fetchImplementation(options.endpoint, { + method: "POST", + headers: { "content-type": "application/json", ...headers() }, + body: JSON.stringify(message), + }), + catch: bridgeError, }); - const initializeResponse = initialized.find((entry) => asEnvelope(entry)?.id === initializeId); - if (initializeResponse === undefined || asEnvelope(initializeResponse)?.error !== undefined) { + sessionId = response.headers.get("mcp-session-id") ?? sessionId; + if (!response.ok) { + yield* discardResponseBody(response); return yield* Effect.fail( - new AcpMcpBridgeError("T3 Code MCP endpoint rejected initialization."), + new AcpMcpBridgeError(`T3 Code MCP endpoint responded with HTTP ${response.status}.`), ); } - yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + const payloads = yield* Stream.runCollect(responsePayloads(response, bridgeError)); + for (const payload of payloads) { + protocolVersion = protocolVersionOf(payload) ?? protocolVersion; + } + return payloads; + }); - const callId = "t3-acp-cli-tool-call"; - const responses = yield* send({ - jsonrpc: "2.0", - id: callId, - method: "tools/call", - params: { name: options.tool, arguments: options.arguments }, - }); - const response = responses.find((entry) => asEnvelope(entry)?.id === callId); - const envelope = asEnvelope(response); + const request = Effect.fn("AcpMcpStdioBridge.request")(function* ( + method: string, + params: unknown, + ): Effect.fn.Return { + const id = `t3-cli-${nextId++}`; + const responses = yield* send({ jsonrpc: "2.0", id, method, params }); + const envelope = asEnvelope(responses.find((entry) => asEnvelope(entry)?.id === id)); if (envelope === null || envelope.error !== undefined) { return yield* Effect.fail( new AcpMcpBridgeError( - `T3 Code MCP tool call failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, + `T3 Code MCP ${method} failed${envelope?.error === undefined ? "." : `: ${JSON.stringify(envelope.error)}`}`, ), ); } - return envelope.result; + return envelope; + }); + + const terminate = Effect.suspend(() => + sessionId === null + ? Effect.void + : Effect.tryPromise(() => + fetchImplementation(options.endpoint, { method: "DELETE", headers: headers() }), + ).pipe(Effect.flatMap(discardResponseBody), Effect.ignore), + ); + + // Registered before the handshake so a session the server opened is ended + // even when the handshake fails, and the handshake stays interruptible. + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => terminate); + yield* request("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: options.clientName, version: "0.0.0" }, + }); + yield* send({ jsonrpc: "2.0", method: "notifications/initialized" }); + return { + call: (tool, args) => + request("tools/call", { name: tool, arguments: args }).pipe( + Effect.map((envelope) => envelope.result), + ), + } satisfies McpToolSession; }); +}; + +/** + * This is the terminal fallback for ACP agents that accept `mcpServers` in + * `session/new` but fail to expose those tools to their model. Compliant ACP + * agents continue to use the stdio bridge above. + */ +function callAcpMcpTool(options: { + readonly endpoint: string; + readonly authorization: string; + readonly tool: string; + readonly arguments: Readonly>; +}): Effect.Effect { + return mcpToolSession({ ...options, clientName: "t3-code-acp-cli" }).pipe( + Effect.flatMap((session) => session.call(options.tool, options.arguments)), + Effect.scoped, + ); } export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect.Effect { diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 87801ea73688..967f9e138c1b 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -1,7 +1,6 @@ import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { - DeviceBootError, DeviceHostUnavailableError, DeviceId, EnvironmentId, @@ -154,47 +153,6 @@ const layerTest = McpHttpServer.layerDeviceToolkit.pipe( Layer.provide(NodeServices.layer), ); -it.effect("returns the complete bounded launch diagnostics to the agent", () => { - const diagnostics = - "Emulator exited with code 1.\nTry manually: emulator -avd TestPhone\n" + "x".repeat(1_930); - const failure = new DeviceBootError({ - hostId: "local", - deviceId: "UDID-1", - reason: "launch_failed", - diagnostics: diagnostics.slice(0, 2_000), - cause: { ok: false, error: diagnostics }, - }); - const failedDevices = Layer.mock(DeviceService.DeviceService)({ - list: Effect.succeed(state), - agentReadinessIfSupported: () => Effect.succeed(readiness), - agentTarget: () => Effect.succeed({ agentDeviceId: "UDID-1", args: [] }), - open: () => Effect.fail(failure), - }); - return Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ name: "device_open", arguments: { platform: "ios" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ type: "text", text: failure.message }]); - }).pipe( - Effect.scoped, - Effect.provide( - McpHttpServer.layerDeviceToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), - Layer.provide(failedDevices), - Layer.provide(layerProjectsMock), - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-boot-error-test-" })), - Layer.provide(NodeServices.layer), - ), - ), - ); -}); - it.effect("registers the device tools and returns the screenshot as image content", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index ac2d5466f2fa..8d8d9ed68774 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,3 +1,5 @@ +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import { expect, it } from "@effect/vitest"; @@ -12,19 +14,11 @@ import { } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; -import { - HttpBody, - HttpClient, - HttpClientRequest, - HttpRouter, - HttpServerResponse, -} from "effect/http"; +import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; import * as ProjectService from "../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; @@ -177,18 +171,6 @@ const layerRecordingTools = (calls: Array) => { ); }; -it("normalizes empty successful notification responses to accepted", () => { - const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( - HttpServerResponse.text("", { status: 200, contentType: "application/json" }), - ); - expect(notificationResponse.status).toBe(202); - - const resultResponse = McpHttpServer.normalizeMcpHttpResponse( - HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), - ); - expect(resultResponse.status).toBe(200); -}); - it.effect.each([{}, { includeImage: false }])( "returns bounded structural preview snapshot failures %#", (input) => @@ -663,24 +645,6 @@ it.effect( }).pipe(Effect.provide(layerPullRequestsTest)), ); -it.effect("returns server ARIA refs to agents in text and structured results", () => - Effect.scoped( - Effect.gen(function* () { - const accessibilityTree = - '- button "delete" [ref=t3-snapshot-e1]\n- iframe [ref=t3-snapshot-e2]:\n - textbox "child" [ref=t3-snapshot-f1e1]'; - yield* serveSnapshots("mcp-server-refs", { ...snapshotResult, accessibilityTree }); - const result = yield* callSnapshot({ includeImage: false }); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ accessibilityTree }); - expect( - result.content.some( - (entry) => entry.type === "text" && entry.text.includes("t3-snapshot-f1e1"), - ), - ).toBe(true); - }), - ).pipe(Effect.provide(layerTest)), -); - it.effect("bounds large ARIA trees without dropping every server locator", () => Effect.scoped( Effect.gen(function* () { @@ -770,6 +734,7 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => ).pipe(Effect.provide(layerTest)), ); +// regression: #10501 keep preview snapshots usable by the agent it.effect("bounds the snapshot text even when nothing but logs and the title are large", () => Effect.scoped( Effect.gen(function* () { @@ -802,6 +767,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are ).pipe(Effect.provide(layerTest)), ); +// regression: #13558 preview snapshots overflowed the agent's tool output it.effect("bounds page text made of wide characters before dropping locators", () => Effect.scoped( Effect.gen(function* () { @@ -840,71 +806,6 @@ it.effect("bounds page text made of wide characters before dropping locators", ( ).pipe(Effect.provide(layerTest)), ); -it.effect("sheds log entries before locators when every list is full", () => - Effect.scoped( - Effect.gen(function* () { - const long = "x".repeat(2_000); - const oversized = { - ...snapshotResult, - interactiveElements: Array.from({ length: 20 }, (_, i) => ({ - tag: "button", - role: "button", - name: `Button ${i}`, - selector: `#button-${i}`, - x: 0, - y: 0, - width: 10, - height: 10, - })), - consoleEntries: Array.from({ length: 200 }, () => ({ - level: long, - text: long, - timestamp: long, - source: long, - })), - networkEntries: Array.from({ length: 200 }, () => ({ - url: long, - method: long, - status: 200, - failed: false, - errorText: long, - timestamp: long, - })), - actionTimeline: Array.from({ length: 200 }, () => ({ - id: long, - action: long, - status: "succeeded", - startedAt: long, - completedAt: long, - error: long, - })), - }; - yield* serveSnapshots("mcp-full-logs-client", oversized); - - const snapshot = yield* callSnapshot({ includeImage: false }); - - const [, text, notice] = snapshot.content; - const body = text?.type === "text" ? text.text : ""; - expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( - McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, - ); - const parsed = decodeJsonText(body) as { - readonly interactiveElements: ReadonlyArray; - readonly consoleEntries: ReadonlyArray; - readonly networkEntries: ReadonlyArray; - readonly actionTimeline: ReadonlyArray; - }; - expect(parsed.interactiveElements).toHaveLength(20); - expect( - parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length, - ).toBeLessThan(120); - const noticeText = notice?.type === "text" ? notice.text : ""; - expect(noticeText).toContain("40 of 40 actionTimeline"); - expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/); - }), - ).pipe(Effect.provide(layerTest)), -); - it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index e3bc3face411..94059d4fd8c5 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -10,39 +10,6 @@ import * as Effect from "effect/Effect"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -it.effect("reports the scoped credential context when preview capability is unavailable", () => { - const invocation: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("environment-1"), - requestNamespace: "provider-session-1", - thread: { - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(), - issuedAt: 1, - }; - - return Effect.gen(function* () { - const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.flip, - ); - - expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); - expect(error).toMatchObject({ - capability: "preview", - environmentId: invocation.environmentId, - threadId: invocation.thread?.threadId, - providerSessionId: invocation.thread?.providerSessionId, - providerInstanceId: invocation.thread?.providerInstanceId, - }); - expect(error.message).toContain("MCP credential does not grant the preview capability"); - expect(error.message).toContain("use a headless browser from the shell"); - }); -}); - it.effect("reports other missing capabilities with the neutral error", () => { const invocation: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("environment-1"), diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 94f6aa677799..282c3dffd6d6 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -102,6 +102,7 @@ it.effect("always grants pull-requests and gates browser and device access indep }), ); +// regression: #3114 use bound host for MCP endpoint it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ @@ -139,27 +140,6 @@ it.effect("expires credentials once their session stops showing signs of life", }), ); -it.effect("keeps a credential alive across turns that never touch an MCP tool", () => - Effect.gen(function* () { - let timestamp = 1_000; - const registry = yield* makeRegistry(() => timestamp); - const threadId = ThreadId.make("thread-3"); - const issued = yield* registry.issue({ - threadId, - providerInstanceId: ProviderInstanceId.make("claude"), - capabilities: new Set(["preview"]), - }); - const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - - for (let turn = 0; turn < 10; turn += 1) { - timestamp += 99; - yield* registry.touch(threadId); - } - - expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId); - }), -); - it.effect("does not keep credentials of other threads alive", () => Effect.gen(function* () { let timestamp = 1_000; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index de5f2b2eb0a7..a6408d8e12cf 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -219,15 +219,6 @@ const make = Effect.acquireRelease( export const layer = Layer.effect(McpSessionRegistry, make); -export const issueActiveMcpCredential = ( - request: McpCredentialRequest, -): Effect.Effect => - activeMcpSessionRegistry - ? activeMcpSessionRegistry - .revokeThread(request.threadId) - .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) - : Effect.undefined; - export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 52b8523ad56d..b02ef2637484 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -188,22 +188,4 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => { ), ), ); - - it.effect("lets the write through when the thread stays within the caller's modes", () => - Effect.gen(function* () { - const projections = yield* ProjectionStore.ProjectionStoreV2; - const threadId = ThreadId.make("thread:race-steady"); - yield* createSupervisedThread(threadId); - const outcome = yield* renameRacingTheUser(threadId, false); - assert.deepEqual(outcome, { renamed: true }); - assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent"); - }).pipe( - Effect.provide( - ThreadManagement.layer.pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - ), - ), - ), - ); }); diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 46371724119a..7bd0d66fa040 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -311,11 +311,6 @@ it.effect.each([ ), ); -it("refuses a declaration or handlers layer built outside McpToolAccess", () => { - expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); - expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); -}); - it("refuses a declaration or handlers layer that only wears another's fields", () => { const copiedDeclaration = Object.assign( unchecked, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index b2cfec0a6bc8..76f55d233dc9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -26,14 +26,12 @@ import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts import * as ServerSettings from "../serverSettings.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { OrchestratorDispatchError } from "../orchestration-v2/Orchestrator.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import type * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; -import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; +import { liveThreadShell } from "./McpToolAccess.testkit.ts"; const layerOrchestratorService = OrchestratorMcpService.layer.pipe( - Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(NodeServices.layer), ); @@ -186,72 +184,6 @@ it("readThread prefers activity-run status over a newer cancelled queued run", a }).pipe(Effect.provide(layer), Effect.runPromise); }); -it("readThread prefers waiting activity status over a newer cancelled queued run", async () => { - const projection = { - thread: baseThread({ - threadId: parentThreadId, - title: "Parent waiting", - instanceId: parentInstanceId, - model: "gpt-5.4", - }), - runs: [ - makeRun({ id: activeRunId, ordinal: 1, status: "waiting" }), - makeRun({ id: cancelledRunId, ordinal: 2, status: "cancelled" }), - ], - visibleTurnItems: [], - runtimeRequests: [], - messages: [], - contextTransfers: [], - subagents: [], - updatedAt: now, - } as unknown as OrchestrationV2ThreadProjection; - - const layer = layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getTimelinePage: () => Effect.succeed({ items: [], totalItems: 0, hasMore: false }), - getThreadRecords: (threadId) => - threadId === parentThreadId - ? Effect.succeed(projection) - : Effect.die(`unexpected thread ${threadId}`), - getThreadShell: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? (projection.thread as unknown as OrchestrationV2ThreadShell) - : null, - ), - getProjectThreadRecords: (input) => - input.threadId === parentThreadId - ? Effect.succeed(projection) - : Effect.die(`unexpected thread ${input.threadId}`), - } satisfies Partial), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - } satisfies Partial), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({ - list: () => Effect.succeed({ tasks: [] }), - } satisfies Partial), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ - list: () => Effect.succeed([]), - } satisfies Partial), - NodeCrypto.layer, - ), - ), - ); - - await Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.readThread(makeScope(), { threadId: parentThreadId }); - expect(result.thread.status).toBe("waiting"); - expect(result.thread.activeRunId).toBe(activeRunId); - }).pipe(Effect.provide(layer), Effect.runPromise); -}); - it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => { const parentProjection = { thread: baseThread({ @@ -437,6 +369,13 @@ it("readThread and sendToThread reach threads in other projects", async () => { Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: [], + }), getThreadRecords: (threadId, _fields, filter) => { if (threadId === parentThreadId) return Effect.succeed(parentProjection); if (threadId === foreignThreadId && filter?.messageIds !== undefined) @@ -656,143 +595,3 @@ it("readThread and sendToThread reach threads in other projects", async () => { expect(stopped.code).toBe("parent_not_active"); }).pipe(Effect.provide(layer), Effect.runPromise); }); - -it("sendToThread reports why the orchestrator refused the message", async () => { - const targetThreadId = ThreadId.make("thread-mcp-orchestrator-dead-session"); - const targetProjection = { - thread: baseThread({ - threadId: targetThreadId, - title: "Stalled", - instanceId: parentInstanceId, - model: "gpt-5.4", - }), - runs: [], - visibleTurnItems: [], - runtimeRequests: [], - messages: [], - contextTransfers: [], - subagents: [], - updatedAt: now, - } as unknown as OrchestrationV2ThreadProjection; - - const layer = layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: (threadId) => - Effect.succeed( - threadId === targetThreadId - ? (targetProjection.thread as unknown as OrchestrationV2ThreadShell) - : liveThreadShell(parentThreadId), - ), - getThreadRecords: (threadId) => - Effect.succeed( - threadId === targetThreadId - ? targetProjection - : ({ - ...targetProjection, - thread: baseThread({ - threadId: parentThreadId, - title: "Parent", - instanceId: parentInstanceId, - model: "gpt-5.4", - }), - runs: [ - makeRun({ id: RunId.make("run-parent-live"), ordinal: 1, status: "running" }), - ], - } as unknown as OrchestrationV2ThreadProjection), - ), - getProjectThreadRecords: () => Effect.succeed(targetProjection), - sendToThread: (input) => - Effect.fail( - new OrchestratorDispatchError({ - commandId: input.commandId, - commandType: "message.dispatch", - cause: "Provider session provider-session-dead is not active.", - }), - ), - } satisfies Partial), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - } satisfies Partial), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ - list: () => Effect.succeed([]), - } satisfies Partial), - NodeCrypto.layer, - ), - ), - ); - - await Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const failure = yield* service - .sendToThread(makeScope(), { threadId: targetThreadId, message: "restart", mode: "restart" }) - .pipe(Effect.flip); - expect(failure.code).toBe("orchestration_error"); - expect(failure.message).toBe( - `Unable to send to thread ${targetThreadId}: Provider session provider-session-dead is not active.`, - ); - }).pipe(Effect.provide(layer), Effect.runPromise); -}); - -it("readThread exposes the stored sender thread only for thread-sent messages", async () => { - const shell = liveThreadShell(parentThreadId); - const messages = [ - { id: "message-from-child", text: "Child reporting in", senderThreadId: childThreadId }, - { id: "message-from-user", text: "Thanks" }, - ]; - const projection = { - ...idleThreadProjection(shell), - messages, - visibleTurnItems: messages.map((message, position) => ({ - position, - visibility: "local", - sourceThreadId: parentThreadId, - sourceItemId: `item-${message.id}`, - item: { - id: `item-${message.id}`, - type: "user_message", - messageId: message.id, - runId: activeRunId, - status: "completed", - title: null, - text: message.text, - updatedAt: now, - }, - })), - } as unknown as OrchestrationV2ThreadProjection; - const layer = layerOrchestratorService.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: () => Effect.succeed(shell), - getThreadRecords: () => Effect.succeed(projection), - getProjectThreadRecords: () => Effect.succeed(projection), - getTimelinePage: () => - Effect.succeed({ items: projection.visibleTurnItems, totalItems: 2, hasMore: false }), - }), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), - NodeCrypto.layer, - ), - ), - ); - - await Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.readThread(makeScope(), { threadId: parentThreadId }); - expect(result.items).toMatchObject([ - { messageId: "message-from-child", senderThreadId: childThreadId }, - { messageId: "message-from-user" }, - ]); - expect(result.items[1]).not.toHaveProperty("senderThreadId"); - }).pipe(Effect.provide(layer), Effect.runPromise); -}); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3872d42e1743..2ec1abbe65a7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -15,6 +15,7 @@ import { ProviderInstanceId, RunId, ThreadId, + type OrchestrationV2ServerCommand, type OrchestrationV2ThreadProjection, type ServerProvider, } from "@t3tools/contracts"; @@ -372,6 +373,116 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect.each(["no runs", "a finished run"] as const)( + "cancels an orphaned follow-up through its parent when the child has %s", + (childState) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-orphan-parent"); + const childThreadId = ThreadId.make("thread:mcp-orphan-child"); + const taskId = NodeId.make("node:mcp-orphan-task"); + const parentRunId = RunId.make("run:mcp-orphan-parent"); + const dispatched = yield* Ref.make>([]); + const status = yield* Ref.make("running"); + const parentProjection = (taskStatus: string) => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: taskStatus, + result: null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + // A finished run whose binding to the follow-up was lost (#19002). + const childProjection = { + thread: { id: childThreadId }, + runs: + childState === "no runs" + ? [] + : [{ id: RunId.make("run:mcp-orphan-child"), ordinal: 1, status: "completed" }], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + threadId === parentThreadId + ? Effect.map(Ref.get(status), parentProjection) + : Effect.succeed(childProjection), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + delegatedTaskResultPending: () => Effect.succeed(false), + stopDelegatedTasks: () => Effect.void, + // The orchestrator's subagent.stop repair is covered in ThreadStop.test.ts. + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + command.type === "subagent.stop" ? Ref.set(status, "cancelled") : Effect.void, + ), + Effect.as({} as never), + ), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-orphan"), + requestNamespace: "provider-session:mcp-orphan", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-orphan", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const result = yield* service.cancelTask(scope, { + taskId, + clientRequestId: "cancel-orphan-task", + }); + assert.deepEqual(result, { taskId, status: "cancelled" }); + const commands = yield* Ref.get(dispatched); + // A child whose run finished can still run another turn's work, so it is stopped too. + assert.deepEqual( + commands.flatMap((command) => + command.type === "thread.stop" ? [command.threadId] : [], + ), + childState === "a finished run" ? [childThreadId] : [], + ); + const stop = commands.find((command) => command.type === "subagent.stop"); + if (stop?.type !== "subagent.stop") return; + assert.deepEqual( + { threadId: stop.threadId, runId: stop.runId, subagentId: stop.subagentId }, + { threadId: parentThreadId, runId: parentRunId, subagentId: taskId }, + ); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when the child stop fails", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-failed-parent"); @@ -1063,330 +1174,6 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("leaves models the user hid out of the capability catalog", () => - Effect.gen(function* () { - const codex = providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }); - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(parentProjection([])), - }), - ServerSettings.layerTest({ - providerModelPreferences: { - [codexInstanceId]: { hiddenModels: ["gpt-5.4-mini", "my-model"], modelOrder: [] }, - }, - }), - providerRegistryLayer([ - { - ...codex, - models: [ - ...codex.models, - { slug: "gpt-5.4-mini", name: "Mini", isCustom: false, capabilities: null }, - { slug: "my-model", name: "Mine", isCustom: true, capabilities: null }, - ], - }, - ]), - adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const capabilities = yield* service.capabilities(scope); - assert.deepEqual( - capabilities.providers[0]?.models.map((model) => model.id), - ["gpt-5.4", "my-model"], - ); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - - it.effect.each([ - { - name: "installation", - snapshot: { installed: false, version: null, status: "warning", auth: { status: "unknown" } }, - constraints: ["Provider executable is not installed."], - }, - { - name: "health", - snapshot: { status: "error", message: "Provider health check failed." }, - constraints: ["Provider health check failed."], - }, - { - name: "authentication", - snapshot: { auth: { status: "unauthenticated" } }, - constraints: ["Provider is not authenticated."], - }, - { - name: "combined installation and health", - snapshot: { - installed: false, - version: null, - status: "error", - message: "Provider executable could not be found.", - auth: { status: "unknown" }, - }, - constraints: [ - "Provider executable is not installed.", - "Provider executable could not be found.", - ], - }, - { - name: "disabled with stale probe failures", - snapshot: { - enabled: false, - installed: false, - version: null, - status: "error", - message: "Stale provider health failure.", - auth: { status: "unauthenticated" }, - }, - constraints: ["Provider instance is disabled."], - }, - ] as const)("reports $name provider constraints", (testCase) => - Effect.gen(function* () { - const provider: ServerProvider = { - ...providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - ...testCase.snapshot, - }; - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadRecords: () => Effect.succeed(parentProjection([])), - }), - ServerSettings.layerTest(), - providerRegistryLayer([provider]), - adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const capabilities = yield* service.capabilities(scope); - const entry = capabilities.providers.find( - (candidate) => candidate.providerInstanceId === codexInstanceId, - ); - assert.isDefined(entry); - assert.isFalse(entry!.canRunChildTask); - assert.isFalse(entry!.canRunCrossProviderChildTask); - assert.deepEqual(entry!.constraints, testCase.constraints); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - - it.effect( - "delegates to an Antigravity instance whose adapter resolves through the registry", - () => - Effect.gen(function* () { - let delegated = false; - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: ProviderDriverKind.make("antigravity"), - providerInstanceId: antigravityInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Summarize the diff.", - title: null, - model: "ant-model", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - const dispatched = yield* Ref.make>([]); - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - ...spawnPolicyReads, - hasReceipt: () => Effect.succeed(false), - assertProviderInstanceEnabled: () => Effect.void, - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? parentProjection(delegated ? [task] : []) - : childProjection, - ), - dispatch: (command) => - Ref.update(dispatched, (commands) => [...commands, command]).pipe( - Effect.andThen( - Effect.sync(() => { - delegated = true; - }), - ), - Effect.as({ - sequence: 1, - storedEvents: [ - { - sequence: 1, - commandId: null, - event: { type: "subagent.updated", payload: task }, - }, - ], - } as never), - ), - }), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - providerSnapshot({ - instanceId: antigravityInstanceId, - driver: ProviderDriverKind.make("antigravity"), - model: "ant-model", - }), - ]), - }), - adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.delegateTask(scope, { - task: "Summarize the diff.", - target: { providerInstanceId: antigravityInstanceId, model: "ant-model" }, - mode: "async", - clientRequestId: "delegate-antigravity-1", - }); - assert.equal(result.status, "running"); - assert.equal(result.providerInstanceId, antigravityInstanceId); - const commands = yield* Ref.get(dispatched); - assert.equal(commands.length, 1); - const request = commands[0] as { - type: string; - modelSelection: { instanceId: string; model: string }; - }; - assert.equal(request.type, "delegated_task.request"); - assert.equal(request.modelSelection.instanceId, antigravityInstanceId); - assert.equal(request.modelSelection.model, "ant-model"); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - - it.effect("resolves a driverKind target to a capable Antigravity instance", () => - Effect.gen(function* () { - const dispatched = yield* Ref.make>([]); - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: ProviderDriverKind.make("antigravity"), - providerInstanceId: antigravityInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Summarize the diff.", - title: null, - model: "ant-model", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - let delegated = false; - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - ...spawnPolicyReads, - hasReceipt: () => Effect.succeed(false), - assertProviderInstanceEnabled: () => Effect.void, - isProviderInstanceEnabledForProject: () => Effect.succeed(true), - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId - ? parentProjection(delegated ? [task] : []) - : childProjection, - ), - dispatch: (command) => - Ref.update(dispatched, (commands) => [...commands, command]).pipe( - Effect.andThen( - Effect.sync(() => { - delegated = true; - }), - ), - Effect.as({ - sequence: 1, - storedEvents: [ - { - sequence: 1, - commandId: null, - event: { type: "subagent.updated", payload: task }, - }, - ], - } as never), - ), - }), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - providerSnapshot({ - instanceId: antigravityInstanceId, - driver: ProviderDriverKind.make("antigravity"), - model: "ant-model", - }), - ]), - }), - adapterRegistryLayer([codexInstanceId, antigravityInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.delegateTask(scope, { - task: "Summarize the diff.", - target: { driverKind: ProviderDriverKind.make("antigravity") }, - mode: "async", - clientRequestId: "delegate-antigravity-driver-1", - }); - assert.equal(result.status, "running"); - const commands = yield* Ref.get(dispatched); - assert.equal(commands.length, 1); - const request = commands[0] as { - modelSelection: { instanceId: string; model: string }; - }; - assert.equal(request.modelSelection.instanceId, antigravityInstanceId); - assert.equal(request.modelSelection.model, "ant-model"); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - it.effect("rejects delegation to a provider without a registered adapter", () => Effect.gen(function* () { const forkOnlyInstanceId = ProviderInstanceId.make("forkOnly"); @@ -1448,52 +1235,6 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("refuses delegation to a provider the project turned off", () => - Effect.gen(function* () { - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - ...spawnPolicyReads, - hasReceipt: () => Effect.succeed(false), - getThreadRecords: () => Effect.succeed(parentProjection([])), - dispatch: () => - Effect.fail( - new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ - projectId, - instanceId: codexInstanceId, - }), - ), - }), - providerRegistryLayer([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - ]), - adapterRegistryLayer([codexInstanceId]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ServerSettings.layerTest(), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const error = yield* service - .delegateTask(scope, { - task: "Summarize the diff.", - target: { providerInstanceId: codexInstanceId }, - mode: "async", - clientRequestId: "delegate-disabled-1", - }) - .pipe(Effect.flip); - assert.equal(error.code, "invalid_request"); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - it.effect("driver-only delegation skips an instance the project turned off", () => Effect.gen(function* () { const altInstanceId = ProviderInstanceId.make("codex-alt"); @@ -2008,113 +1749,6 @@ describe("OrchestratorMcpService provider resolution", () => { ), ); - it.effect("re-probes an unavailable target once before refusing it", () => - Effect.gen(function* () { - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const task = { - id: taskId, - threadId: parentThreadId, - runId: parentRunId, - parentNodeId, - origin: "app_owned", - createdBy: "agent", - driver: claudeDriver, - providerInstanceId: claudeInstanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: null, - prompt: "Review the diff.", - title: null, - model: "claude-opus-5-5", - status: "running", - result: null, - startedAt: null, - completedAt: null, - }; - const healthy = providerSnapshot({ - instanceId: claudeInstanceId, - driver: claudeDriver, - model: "claude-opus-5-5", - }); - const missingCli: ServerProvider = { - ...healthy, - installed: false, - status: "error", - message: "Claude Agent CLI (`claude`) was not found on PATH.", - }; - const codex = providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }); - let cliInstalled = false; - const probes = yield* Ref.make(0); - const dispatched = yield* Ref.make(0); - const layerDependencies = Layer.mergeAll( - NodeServices.layer, - Layer.mock(ThreadManagementService.ThreadManagementService)({ - ...spawnPolicyReads, - hasReceipt: () => Effect.succeed(false), - assertProviderInstanceEnabled: () => Effect.void, - getThreadRecords: (threadId) => - Effect.succeed( - threadId === parentThreadId ? parentProjection([task]) : childProjection, - ), - dispatch: () => - Ref.update(dispatched, (count) => count + 1).pipe( - Effect.as({ - sequence: 1, - storedEvents: [ - { - sequence: 1, - commandId: null, - event: { type: "subagent.updated", payload: task }, - }, - ], - } as never), - ), - }), - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([codex, missingCli]), - refreshInstance: () => - Ref.update(probes, (count) => count + 1).pipe( - Effect.as([codex, cliInstalled ? healthy : missingCli]), - ), - }), - adapterRegistryLayer([codexInstanceId, claudeInstanceId]), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - ); - - yield* Effect.gen(function* () { - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const delegate = (clientRequestId: string) => - service.delegateTask(scope, { - task: "Review the diff.", - target: { providerInstanceId: claudeInstanceId, model: "claude-opus-5-5" }, - mode: "async", - clientRequestId, - }); - - const error = yield* delegate("delegate-recheck-1").pipe(Effect.flip); - assert.equal(error.code, "provider_unavailable"); - assert.equal(yield* Ref.get(probes), 1); - assert.equal(yield* Ref.get(dispatched), 0); - - cliInstalled = true; - const result = yield* delegate("delegate-recheck-2"); - assert.equal(result.providerInstanceId, claudeInstanceId); - assert.equal(yield* Ref.get(probes), 2); - assert.equal(yield* Ref.get(dispatched), 1); - }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); - }), - ); - it.effect( "inherits an available parent instance for driver-only targets and otherwise selects a healthy peer", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 8955454e4948..a2ec0d9f7f69 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -65,6 +65,7 @@ import { type ScheduledTaskUpsertInput, type ServerProvider, ThreadId, + isOrchestrationV2WorkActive, } from "@t3tools/contracts"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { @@ -432,7 +433,7 @@ function taskStatusForRun( } } -export function delegatedTaskRun( +function delegatedTaskRun( childProjection: Pick, task: OrchestrationV2Subagent, ): OrchestrationV2Run | undefined { @@ -452,7 +453,7 @@ export function delegatedTaskRun( : childProjection.runs.find((run) => run.id === spawnTransfer.targetRunId); } -export function hasPendingChildRuns( +function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { @@ -2558,12 +2559,37 @@ const make = Effect.gen(function* () { ), ), ); + // A follow-up that lost its child run (#19002) stays running on the parent, while its + // status here comes from the child's last run. Stop on the parent cancels that task. + const cancelOrphan = Effect.gen(function* () { + if (parentTask?.runId == null || !isOrchestrationV2WorkActive(parentTask.status)) + return false; + yield* threadManagement + .dispatch({ + type: "subagent.stop", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "cancel-task-orphan", + }), + threadId: scope.thread.threadId, + runId: parentTask.runId, + subagentId: parentTask.id, + }) + .pipe(Effect.mapError(threadManagementFailure)); + const { subagents } = yield* threadManagement + .getThreadRecords(scope.thread.threadId, ["subagents"]) + .pipe(Effect.mapError(threadManagementFailure)); + return subagents.some((task) => task.id === input.taskId && task.status === "cancelled"); + }); if (isTerminalTaskStatus(current.status)) { + // The child can still run work for another turn, so it is stopped either way. yield* stopWithinLimit; + const cancelled = yield* cancelOrphan; yield* disposeCompletionDelivery; return { taskId: input.taskId, - status: current.status, + status: cancelled ? "cancelled" : current.status, } satisfies OrchestratorMcpTaskCancelResult; } const child = yield* loadProjection(current.childThreadId); @@ -2576,6 +2602,7 @@ const make = Effect.gen(function* () { (run.delegatedTaskId === input.taskId || run.id === current.childRunId), ) ) { + if (yield* cancelOrphan) return { taskId: input.taskId, status: "cancelled" }; return yield* failure( "task_not_cancellable", `Delegated task ${input.taskId} has no interruptible child run.`, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 73f7094298e0..9055a37cb102 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,76 +1,37 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; -import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; import { CommandId, EnvironmentId, - EventId, - IsoDateTime, isProviderNativeSubagentThread, MessageId, - NodeId, - RunId, type ModelSelection, - type OrchestrationV2DelegatedCompletionDelivery, - type OrchestrationV2DomainEvent, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2RuntimeRequest, - type OrchestrationV2ThreadProjection, - OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskResult, - OrchestratorMcpTaskCancelResult, OrchestratorMcpThreadInterruptResult, - OrchestratorMcpThreadListResult, - OrchestratorMcpThreadReadResult, OrchestratorMcpThreadSendResult, - OrchestratorMcpThreadWaitResult, ProjectId, ProviderDriverKind, ProviderInstanceId, type ProviderOptionDescriptor, - ProviderThreadId, - ProviderTurnId, - type ScheduledTask, - RuntimeRequestId, - ScheduledTaskId, - type ScheduledTaskUpsertInput, type ServerProvider, ThreadId, - ThreadMetadataMcpUpdateResult, - TurnItemId, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/ai"; -import { ClaudeProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { CodexOrchestratorReplayHarness } from "../orchestration-v2/Adapters/CodexAdapterV2.testkit.ts"; -import { threadShellFromProjection } from "../orchestration-v2/ProjectionStore.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; -import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; -import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts"; -import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; @@ -80,16 +41,12 @@ import { } from "@t3tools/provider-testing/replayTranscript"; import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts"; import * as ProjectService from "../project/ProjectService.ts"; -import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; -import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as McpContextLinks from "./McpContextLinks.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -98,48 +55,29 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( // Effect returns a declared tool failure as `isError` with its encoded payload // as JSON text, never as `structuredContent`. -const declaredFailure = (result: McpSchema.CallToolResult) => { - const text = result.content[0]; - return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; -}; const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); const codexInstanceId = ProviderInstanceId.make("codex"); -const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); const codexModel = "gpt-5.4"; -const claudeModel = "claude-opus-4-6"; const parentPrompt = "Keep this parent turn active while orchestration tools are tested."; const delegatedPrompt = "Inspect the delegated API boundary and return the result."; const delegatedResult = "Delegated API boundary inspected."; const cancellationPrompt = "Remain active until the parent cancels this delegated task."; -const createdThreadPrompt = "Complete the newly created ordinary thread."; const queuedFollowupPrompt = "Complete the queued follow-up and return the final result."; const queuedFollowupResult = "Queued delegated follow-up completed."; -const approvalCompletionPrompt = "Finish the delegated work once its approval is decided."; -const decodeCreateThreadsResult = Schema.decodeUnknownEffect(OrchestratorMcpCreateThreadsResult); const decodeDelegateTaskResult = Schema.decodeUnknownEffect(OrchestratorMcpDelegateTaskResult); -const decodeTaskCancelResult = Schema.decodeUnknownEffect(OrchestratorMcpTaskCancelResult); const decodeThreadInterruptResult = Schema.decodeUnknownEffect( OrchestratorMcpThreadInterruptResult, ); -const decodeThreadListResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadListResult); -const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); const decodeThreadSendResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadSendResult); -const decodeThreadWaitResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadWaitResult); -const decodeThreadUpdateResult = Schema.decodeUnknownEffect(ThreadMetadataMcpUpdateResult); const codexSelection = { instanceId: codexInstanceId, model: codexModel, } satisfies ModelSelection; -const claudeSelection = { - instanceId: claudeInstanceId, - model: claudeModel, -} satisfies ModelSelection; - const delegatedTaskStatusTranscriptFile = new URL( "../orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson", import.meta.url, @@ -155,17 +93,6 @@ const readDelegatedTaskStatusTranscript = Effect.fn("readDelegatedTaskStatusTran return yield* decodeProviderReplayNdjson(text.replaceAll(queuedFollowupResult, followupResult)); }, Effect.provide(NodeServices.layer)); -interface CapturedTurn { - readonly instanceId: ProviderInstanceId; - readonly threadId: ThreadId; - readonly text: string; - readonly modelSelection: ModelSelection; -} - -function unsupported(driver: ProviderDriverKind, detail: string) { - return Effect.fail(new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail })); -} - function makeProviderSnapshot(input: { readonly instanceId: ProviderInstanceId; readonly driver: ProviderDriverKind; @@ -197,262 +124,6 @@ function makeProviderSnapshot(input: { }; } -function makeDeterministicAdapter(input: { - readonly instanceId: ProviderInstanceId; - readonly driver: ProviderDriverKind; - readonly capabilities: OrchestrationV2ProviderCapabilities; - readonly capturedTurns: Ref.Ref>; - readonly shouldComplete: (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => boolean; - readonly terminalGate?: ( - turn: ProviderAdapter.ProviderAdapterV2TurnInput, - ) => Deferred.Deferred | undefined; - readonly response: (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => string; - readonly steerReceived?: ( - steer: ProviderAdapter.ProviderAdapterV2SteerInput, - ) => Deferred.Deferred | undefined; -}): ProviderAdapter.ProviderAdapterV2["Service"] { - return { - instanceId: input.instanceId, - driver: input.driver, - getCapabilities: () => Effect.succeed(input.capabilities), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (sessionInput) => - Effect.gen(function* () { - const events = yield* PubSub.unbounded(); - const now = yield* DateTime.now; - const providerSession: OrchestrationV2ProviderSession = { - id: sessionInput.providerSessionId, - driver: input.driver, - providerInstanceId: input.instanceId, - status: "ready", - cwd: sessionInput.runtimePolicy.cwd ?? process.cwd(), - model: sessionInput.modelSelection.model, - capabilities: input.capabilities, - createdAt: now, - updatedAt: now, - lastError: null, - }; - - const publish = (providerEvents: ReadonlyArray) => - Effect.forEach(providerEvents, (event) => PubSub.publish(events, event), { - discard: true, - }); - const runOrdinals = new Map(); - const turnInputs = new Map(); - - return { - instanceId: input.instanceId, - driver: input.driver, - providerSessionId: sessionInput.providerSessionId, - providerSession, - events: Stream.fromPubSub(events), - ensureThread: (threadInput) => - Effect.gen(function* () { - const createdAt = yield* DateTime.now; - const nativeThreadId = `${input.driver}:${threadInput.threadId}`; - return { - id: ProviderThreadId.make(`provider-thread:${nativeThreadId}`), - driver: input.driver, - providerInstanceId: input.instanceId, - providerSessionId: sessionInput.providerSessionId, - appThreadId: threadInput.threadId, - ownerNodeId: null, - nativeThreadRef: { - driver: input.driver, - nativeId: nativeThreadId, - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt, - updatedAt: createdAt, - } satisfies OrchestrationV2ProviderThread; - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (turnInput) => - Effect.gen(function* () { - yield* Ref.update(input.capturedTurns, (turns) => [ - ...turns, - { - instanceId: input.instanceId, - threadId: turnInput.threadId, - text: turnInput.message.text, - modelSelection: turnInput.modelSelection, - }, - ]); - const eventTime = yield* DateTime.now; - const providerTurnId = ProviderTurnId.make( - `provider-turn:${input.instanceId}:${turnInput.threadId}:${turnInput.runOrdinal}`, - ); - runOrdinals.set(providerTurnId, turnInput.runOrdinal); - turnInputs.set(providerTurnId, turnInput); - yield* publish([ - { - type: "provider_turn.updated", - driver: input.driver, - providerTurn: { - id: providerTurnId, - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: { - driver: input.driver, - nativeId: `native-turn:${turnInput.threadId}:${turnInput.runOrdinal}`, - strength: "strong", - }, - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt: eventTime, - completedAt: null, - }, - }, - ]); - const terminalGate = input.terminalGate?.(turnInput); - if (terminalGate !== undefined) { - yield* Deferred.await(terminalGate); - } else if (!input.shouldComplete(turnInput)) { - return; - } - const response = input.response(turnInput); - yield* publish([ - { - type: "provider_turn.updated", - driver: input.driver, - providerTurn: { - id: providerTurnId, - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: { - driver: input.driver, - nativeId: `native-turn:${turnInput.threadId}:${turnInput.runOrdinal}`, - strength: "strong", - }, - ordinal: turnInput.providerTurnOrdinal, - status: "completed", - startedAt: eventTime, - completedAt: eventTime, - }, - }, - { - type: "turn_item.updated", - driver: input.driver, - turnItem: { - id: TurnItemId.make( - `turn-item:${input.instanceId}:${turnInput.threadId}:${turnInput.runOrdinal}:assistant`, - ), - threadId: turnInput.threadId, - runId: turnInput.runId, - nodeId: turnInput.rootNodeId, - providerThreadId: turnInput.providerThread.id, - providerTurnId, - nativeItemRef: null, - parentItemId: null, - ordinal: turnInput.runOrdinal * 100 + 1, - status: "completed", - title: null, - startedAt: eventTime, - completedAt: eventTime, - updatedAt: eventTime, - type: "assistant_message", - messageId: MessageId.make( - `message:${input.instanceId}:${turnInput.threadId}:${turnInput.runOrdinal}:assistant`, - ), - text: response, - streaming: false, - }, - }, - { - type: "turn.terminal", - driver: input.driver, - providerThreadId: turnInput.providerThread.id, - providerTurnId, - runOrdinal: turnInput.runOrdinal, - status: "completed", - failure: null, - threadDisposition: "reusable", - }, - ]); - }), - steerTurn: (steer) => { - const received = input.steerReceived?.(steer); - return received === undefined - ? Effect.void - : Deferred.succeed(received, undefined).pipe(Effect.asVoid); - }, - interruptTurn: ({ providerThread, providerTurnId }) => - Effect.gen(function* () { - const turnInput = turnInputs.get(providerTurnId); - const completedAt = yield* DateTime.now; - if (turnInput !== undefined) { - yield* publish([ - { - type: "provider_turn.updated", - driver: input.driver, - providerTurn: { - id: providerTurnId, - providerThreadId: providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: { - driver: input.driver, - nativeId: `native-turn:${turnInput.threadId}:${turnInput.runOrdinal}`, - strength: "strong", - }, - ordinal: turnInput.providerTurnOrdinal, - status: "interrupted", - startedAt: completedAt, - completedAt, - }, - }, - ]); - } - yield* publish([ - { - type: "turn.terminal", - driver: input.driver, - providerThreadId: providerThread.id, - providerTurnId, - runOrdinal: runOrdinals.get(providerTurnId) ?? 1, - status: "interrupted", - failure: null, - threadDisposition: "reusable", - }, - ]); - }), - respondToRuntimeRequest: () => Effect.void, - readThreadSnapshot: () => - unsupported(input.driver, "readThreadSnapshot is unused in this test"), - rollbackThread: () => unsupported(input.driver, "rollbackThread is unused in this test"), - forkThread: () => unsupported(input.driver, "forkThread is unused in this test"), - }; - }), - }; -} - -function waitForProjection( - orchestrator: Orchestrator.OrchestratorV2Shape, - threadId: ThreadId, - predicate: (projection: OrchestrationV2ThreadProjection) => boolean, -) { - return Effect.gen(function* () { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if (predicate(projection)) { - return projection; - } - yield* Effect.sleep("5 millis"); - } - return yield* Effect.die( - new Error(`Timed out waiting for orchestration projection ${threadId}.`), - ); - }); -} - const client = McpSchema.McpServerClient.of({ clientId: 1, protocolVersion: "2025-06-18", @@ -466,45 +137,6 @@ const client = McpSchema.McpServerClient.of({ getClient: Effect.die("unused"), }); -function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask { - const timestamp = IsoDateTime.make("2026-07-01T09:00:00.000Z"); - return { - id: input.id ?? ScheduledTaskId.make(`scheduled-task:${input.commandId ?? "stub"}`), - title: input.title, - prompt: input.prompt, - enabled: input.enabled, - schedule: - input.schedule.type === "webhook" - ? { - type: "webhook", - signature: - input.schedule.signature == null - ? null - : { - header: input.schedule.signature.header, - encoding: input.schedule.signature.encoding, - prefix: input.schedule.signature.prefix, - }, - } - : input.schedule, - projectId: input.projectId, - threadId: input.threadId ?? null, - workspaceStrategy: input.workspaceStrategy, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - createdBy: input.createdBy ?? "user", - creationSource: input.creationSource ?? "web", - createdAt: timestamp, - updatedAt: timestamp, - nextRunAt: null, - lastRunAt: null, - lastRunStatus: "never", - lastRunError: null, - runCount: 0, - }; -} - const layerMemorySecretStore = Layer.sync(ServerSecretStore.ServerSecretStore, () => { const stored = new Map(); return ServerSecretStore.ServerSecretStore.of({ @@ -545,3837 +177,6 @@ const layerUnusedScheduledTaskStub = Layer.succeed( ); describe("orchestrator MCP toolkit", () => { - it.live( - "delegates cross-provider tasks, polls and cancels children, and creates ordinary threads", - () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit"); - const capturedTurns = yield* Ref.make>([]); - const steerReceivedGates = new Map>(); - const parentTerminalGates = new Map>(); - const deliveryTerminalGates = new Map>(); - // Holds a delegated child whose thread id is not known until it starts. - const promptTerminalGates = new Map>(); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ - makeDeterministicAdapter({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - capabilities: { - ...CodexProviderCapabilitiesV2, - turns: { ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: false }, - }, - capturedTurns, - shouldComplete: (turn) => - turn.threadId !== parentThreadId && turn.message.text !== cancellationPrompt, - terminalGate: (turn) => - turn.message.text.startsWith("Delegated task") || - turn.message.text.startsWith("Delegated tasks") - ? deliveryTerminalGates.get(turn.threadId) - : (parentTerminalGates.get(turn.threadId) ?? - promptTerminalGates.get(turn.message.text)), - response: (turn) => `Codex completed: ${turn.message.text}`, - }), - makeDeterministicAdapter({ - instanceId: claudeInstanceId, - driver: ProviderDriverKind.make("claudeAgent"), - capabilities: ClaudeProviderCapabilitiesV2, - capturedTurns, - shouldComplete: (turn) => turn.message.text !== cancellationPrompt, - terminalGate: (turn) => - turn.message.text.startsWith("Delegated task") || - turn.message.text.startsWith("Delegated tasks") - ? deliveryTerminalGates.get(turn.threadId) - : parentTerminalGates.get(turn.threadId), - response: (turn) => - turn.message.text === delegatedPrompt - ? delegatedResult - : `Claude completed: ${turn.message.text}`, - steerReceived: (steer) => steerReceivedGates.get(steer.message.text), - }), - ]); - const approvalParentId = ThreadId.make("thread:mcp-approval-parent"); - const approvalOffered = yield* Deferred.make(); - const continuationOffers = yield* Ref.make< - ReadonlyArray - >([]); - const layerContinuationProbe = Layer.succeed( - ProviderContinuationRequests.ProviderContinuationRequests, - { - offer: (request) => - Ref.update(continuationOffers, (existing) => [...existing, request]).pipe( - Effect.andThen( - request.threadId === approvalParentId - ? Deferred.succeed(approvalOffered, undefined) - : Effect.void, - ), - ), - take: Effect.never, - }, - ); - const waitForContinuationOffers = (count: number) => - Effect.gen(function* () { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - const current = yield* Ref.get(continuationOffers); - if (current.length >= count) { - return current; - } - yield* Effect.sleep("5 millis"); - } - return yield* Ref.get(continuationOffers); - }); - const expectOffersToStay = (count: number) => - Effect.gen(function* () { - for (let sample = 0; sample < 4; sample += 1) { - yield* Effect.sleep("50 millis"); - expect(yield* Ref.get(continuationOffers)).toHaveLength(count); - } - }); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { - name: "orchestrator-mcp-toolkit", - runtimePolicyOverride: { - cwd, - approvalPolicy: "never", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }, - }, - layerRegistry, - ).pipe(Layer.provide(layerContinuationProbe)); - const layerOrchestration = Layer.merge( - layerOrchestrator, - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ), - ), - ); - const layerProviderRegistry = ProviderRegistryMock.layer([ - makeProviderSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: codexModel, - optionDescriptors: [ - { - id: "reasoning", - label: "Reasoning effort", - type: "select", - options: [ - { id: "low", label: "Low" }, - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - ], - }), - makeProviderSnapshot({ - instanceId: claudeInstanceId, - driver: ProviderDriverKind.make("claudeAgent"), - model: claudeModel, - optionDescriptors: [ - { - id: "effort", - label: "Reasoning", - type: "select", - options: [ - { id: "high", label: "High" }, - { id: "max", label: "Max" }, - ], - }, - { id: "fastMode", label: "Fast mode", type: "boolean" }, - ], - }), - makeProviderSnapshot({ - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - model: "opencode/test", - }), - ]); - const scheduledStore = yield* Ref.make>([]); - const layerScheduledTaskStub = Layer.succeed( - ScheduledTaskService.ScheduledTaskService, - ScheduledTaskService.ScheduledTaskService.of({ - list: () => Ref.get(scheduledStore).pipe(Effect.map((tasks) => ({ tasks }))), - subscribeList: () => Stream.empty, - upsert: (input) => - Effect.gen(function* () { - const task = scheduledTaskFromUpsert(input); - yield* Ref.update(scheduledStore, (all) => [ - ...all.filter((candidate) => candidate.id !== task.id), - task, - ]); - return { task }; - }), - setEnabled: () => - Effect.die("ScheduledTaskService.setEnabled is unused in this test"), - delete: (input) => - Ref.update(scheduledStore, (all) => - all.filter((candidate) => candidate.id !== input.id), - ).pipe(Effect.as({ id: input.id })), - deleteForThread: () => Effect.die("unused in this test"), - runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), - rotateWebhookToken: () => Effect.die("unused in this test"), - listWebhookDeliveries: () => Effect.die("unused in this test"), - getWebhookDelivery: () => Effect.die("unused in this test"), - triggerWebhook: () => Effect.die("unused in this test"), - }), - ); - const layerTest = Layer.merge( - layerOrchestratorToolkit, - McpHttpServer.layerThreadToolkit, - ).pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(layerOrchestration), - Layer.provide(layerRegistry), - Layer.provide(layerProviderRegistry), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerScheduledTaskStub), - Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), - Layer.provide(layerNoContextLinks), - Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), - Layer.provide(Layer.mock(GitWorkflow.GitWorkflowService)({})), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide(Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({})), - Layer.provide( - Layer.mock(ProjectService.ProjectService)({ - getById: (id) => - Effect.succeed( - id === projectId - ? Option.some({ id, defaultModelSelection: null } as never) - : Option.none(), - ), - }), - ), - Layer.provideMerge( - SecretRequests.layer.pipe( - Layer.provide(layerMemorySecretStore), - Layer.provide(layerOrchestration), - ), - ), - Layer.provide(NodeServices.layer), - ); - - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const server = yield* McpServer.McpServer; - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:create"), - threadId: parentThreadId, - projectId, - title: "MCP parent", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:start"), - threadId: parentThreadId, - messageId: MessageId.make("message:mcp-parent:start"), - text: parentPrompt, - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - const parent = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some((run) => - ["starting", "running", "waiting"].includes(run.status), - ) && projection.providerTurns.some((turn) => turn.status === "running"), - ); - const parentRun = parent.runs[0]; - expect(parentRun?.status).toBe("running"); - - const invocation: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("environment:mcp-orchestrator"), - requestNamespace: "mcp-provider-session-parent", - thread: { - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-parent", - providerInstanceId: codexInstanceId, - }, - client: undefined, - capabilities: new Set(["orchestration"]), - issuedAt: 1, - }; - const invokeAs = ( - callScope: McpInvocationContext.McpInvocationScope, - name: string, - args: Record, - ) => - server - .callTool({ name, arguments: args }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, callScope), - Effect.provideService(McpSchema.McpServerClient, client), - ); - const invoke = (name: string, args: Record) => - invokeAs(invocation, name, args); - - const deferredSettle = yield* invoke("t3_thread_organize", { action: "settle" }); - expect(deferredSettle.isError).toBe(false); - expect(deferredSettle.structuredContent).toEqual({ settlesWhenTurnEnds: true }); - const afterDeferredSettle = yield* orchestrator.getThreadProjection(parentThreadId); - expect(afterDeferredSettle.thread.settledOverride).not.toBe("settled"); - expect(afterDeferredSettle.runs.find((run) => run.id === parentRun?.id)?.status).toBe( - "running", - ); - - const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); - expect(pinned.isError).toBe(false); - expect(pinned.structuredContent).toHaveProperty("sequence"); - expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); - yield* invoke("t3_thread_organize", { action: "unpin" }); - expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).toBeNull(); - - const markedRead = yield* invoke("t3_thread_organize", { action: "mark_read" }); - expect(markedRead.isError).toBe(false); - const readShell = yield* orchestrator.getThreadShell(parentThreadId); - expect(readShell?.lastVisitedAt).toEqual(readShell?.updatedAt); - - if (parentRun === undefined || parentRun.rootNodeId === null) { - return yield* Effect.die(new Error("Parent run missing.")); - } - for (const name of ["t3_queue_edit", "t3_queue_cancel"]) { - const refusedQueueMutation = yield* invoke(name, { - queuedRunId: parentRun.id, - ...(name === "t3_queue_edit" ? { text: "Keep the active turn." } : {}), - }); - expect(refusedQueueMutation.isError).toBe(true); - expect(refusedQueueMutation.structuredContent).toBeUndefined(); - expect(declaredFailure(refusedQueueMutation)).toEqual({ - _tag: "OrchestratorMcpFailure", - code: "orchestration_error", - message: `Run ${parentRun.id} is not queued.`, - }); - } - - let parentRootNodeId = parentRun.rootNodeId; - const queueAutomaticCompletion = (suffix: string, taskText: string) => - Effect.gen(function* () { - const delegated = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make(`command:mcp-parent:${suffix}:delegate`), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: taskText, - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const taskEvent = delegated.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - return yield* Effect.die( - new Error(`Automatic completion task ${suffix} was not created.`), - ); - } - const task = taskEvent.event.payload; - const reserved = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => { - const delivery = projection.runs.find((run) => run.id === parentRun.id) - ?.delegatedCompletion?.delivery; - return ( - projection.subagents.find((candidate) => candidate.id === task.id)?.status === - "completed" && - delivery !== undefined && - delivery !== null && - delivery.taskIds.includes(task.id) - ); - }, - ); - const delivery = reserved.runs.find((run) => run.id === parentRun.id) - ?.delegatedCompletion?.delivery; - if (delivery === undefined || delivery === null) { - return yield* Effect.die( - new Error(`Automatic completion delivery ${suffix} was not reserved.`), - ); - } - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make(`command:mcp-parent:${suffix}:dispatch`), - threadId: parentThreadId, - messageId: delivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: parentRun.id, - generation: delivery.generation, - taskIds: delivery.taskIds, - }, - }); - const queued = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some( - (run) => run.userMessageId === delivery.messageId && run.status === "queued", - ), - ); - const queuedRun = queued.runs.find( - (run) => run.userMessageId === delivery.messageId, - ); - if (queuedRun === undefined) { - return yield* Effect.die( - new Error(`Automatic completion delivery ${suffix} was not queued.`), - ); - } - return { task, delivery, queuedRun }; - }); - - const coalescedTask = (suffix: string) => - orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make(`command:mcp-parent:coalesced-${suffix}`), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: `Complete coalesced task ${suffix}.`, - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const firstCoalesced = yield* coalescedTask("one"); - const secondCoalesced = yield* coalescedTask("two"); - const coalescedTaskIds = [firstCoalesced, secondCoalesced].map((result) => { - const taskEvent = result.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - throw new Error("Coalesced delegated task projection missing."); - } - return taskEvent.event.payload.id; - }); - const coalescedProjection = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - coalescedTaskIds.every( - (taskId) => - projection.subagents.find((task) => task.id === taskId)?.status === "completed", - ) && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion !== - undefined, - ); - const coalescedCohort = coalescedProjection.runs.find( - (run) => run.id === parentRun.id, - )?.delegatedCompletion; - expect(coalescedCohort?.delivery?.taskIds).toEqual( - expect.arrayContaining(coalescedTaskIds), - ); - expect(coalescedCohort?.delivery?.taskIds).toHaveLength(2); - const coalescedOffers = yield* waitForContinuationOffers(1); - expect(coalescedOffers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - delegatedCompletion: expect.objectContaining({ - parentRunId: parentRun.id, - messageId: coalescedCohort?.delivery?.messageId, - }), - }), - ]), - ); - if (coalescedCohort?.delivery === undefined || coalescedCohort.delivery === null) { - return yield* Effect.die(new Error("Coalesced delivery reservation missing.")); - } - const coalescedDelivery = coalescedCohort.delivery; - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-parent:dispatch-coalesced-delivery"), - threadId: parentThreadId, - messageId: coalescedDelivery.messageId, - text: "Delegated tasks reached terminal states.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: parentRun.id, - generation: coalescedDelivery.generation, - taskIds: coalescedDelivery.taskIds, - }, - }); - const queuedCoalesced = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === coalescedDelivery.messageId && run.status === "queued", - ), - ); - const queuedCoalescedRun = queuedCoalesced.runs.find( - (run) => run.userMessageId === coalescedDelivery.messageId, - ); - expect(queuedCoalescedRun?.status).toBe("queued"); - if (queuedCoalescedRun === undefined) { - return yield* Effect.die(new Error("Queued coalesced delivery missing.")); - } - - const completionEditError = yield* orchestrator - .dispatch({ - type: "queued-run.edit", - commandId: CommandId.make("command:mcp-parent:edit-coalesced-delivery"), - threadId: parentThreadId, - runId: queuedCoalescedRun.id, - text: "Rewrite the automatic delivery.", - }) - .pipe(Effect.flip); - expect(completionEditError._tag).toBe("OrchestratorCommandRejectedError"); - const completionReorderError = yield* orchestrator - .dispatch({ - type: "queued-run.reorder", - commandId: CommandId.make("command:mcp-parent:reorder-coalesced-delivery"), - threadId: parentThreadId, - runId: queuedCoalescedRun.id, - beforeRunId: null, - }) - .pipe(Effect.flip); - expect(completionReorderError._tag).toBe("OrchestratorDispatchError"); - const completionSteerError = yield* orchestrator - .dispatch({ - type: "queued-message.promote-to-steer", - commandId: CommandId.make("command:mcp-parent:steer-coalesced-delivery"), - threadId: parentThreadId, - queuedRunId: queuedCoalescedRun.id, - targetRunId: parentRun.id, - }) - .pipe(Effect.flip); - expect(completionSteerError._tag).toBe("OrchestratorDispatchError"); - - for (const taskId of coalescedTaskIds) { - const statusCall = yield* invoke("task_status", { taskId }); - expect(statusCall.isError).toBe(false); - } - const acknowledgedCoalesced = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === queuedCoalescedRun.id)?.status === - "cancelled" && - coalescedTaskIds.every( - (taskId) => - projection.subagents.find((task) => task.id === taskId)?.completionDelivery - ?.state === "acknowledged", - ), - ); - expect( - acknowledgedCoalesced.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery, - ).toBeNull(); - yield* Ref.set(continuationOffers, []); - - const directRead = yield* queueAutomaticCompletion( - "direct-child-read", - "Complete before a parent reads this child result directly.", - ); - if (directRead.task.childThreadId === null) { - return yield* Effect.die(new Error("Direct-read child thread missing.")); - } - const directChildThreadId = directRead.task.childThreadId; - const directChildWaitCall = yield* invoke("t3_thread_wait", { - threadId: directChildThreadId, - timeoutMs: 10_000, - }); - const directChildWait = yield* decodeThreadWaitResult( - directChildWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(directChildWait).toMatchObject({ - threadId: directChildThreadId, - status: "completed", - timedOut: false, - }); - const pendingAfterWait = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - pendingAfterWait.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery?.state, - ).toBe("claimed"); - expect( - pendingAfterWait.runs.find((run) => run.id === directRead.queuedRun.id)?.status, - ).toBe("queued"); - - const childPromptReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - limit: 1, - }); - const childPromptRead = yield* decodeThreadReadResult( - childPromptReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(childPromptRead.items.map((item) => item.type)).toEqual(["user_message"]); - if (childPromptRead.nextPosition === null) { - return yield* Effect.die(new Error("Direct-read child prompt position missing.")); - } - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const truncatedResultReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - afterPosition: childPromptRead.nextPosition, - limit: 1, - maxCharsPerItem: 1, - }); - const truncatedResultRead = yield* decodeThreadReadResult( - truncatedResultReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(truncatedResultRead.items).toMatchObject([ - { type: "assistant_message", textTruncated: true }, - ]); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const firstChunk = truncatedResultRead.items[0]!; - expect(firstChunk.nextTextOffset).toBe(1); - const remainderCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - itemId: firstChunk.itemId, - textOffset: firstChunk.nextTextOffset, - maxCharsPerItem: 50_000, - }); - const remainder = yield* decodeThreadReadResult(remainderCall.structuredContent).pipe( - Effect.orDie, - ); - expect(remainder.items[0]?.nextTextOffset).toBeNull(); - expect(firstChunk.text + (remainder.items[0]?.text ?? "")).toBe( - "Claude completed: Complete before a parent reads this child result directly.", - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === directRead.task.id, - )?.completionDelivery?.state, - ).toBe("claimed"); - - const terminalResultReadCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - afterPosition: childPromptRead.nextPosition, - limit: 1, - }); - const terminalResultRead = yield* decodeThreadReadResult( - terminalResultReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(terminalResultRead.items).toMatchObject([ - { - type: "assistant_message", - text: "Claude completed: Complete before a parent reads this child result directly.", - textTruncated: false, - }, - ]); - const longText = "界🧪\n".repeat(20_000) + "FINAL_CONSTRAINT"; - const childProjection = yield* orchestrator.getThreadProjection(directChildThreadId); - const sourceItem = childProjection.turnItems.find( - (item) => item.type === "user_message", - )!; - const oversizedItem = { - ...sourceItem, - id: TurnItemId.make("item:oversized-retrieval"), - type: "assistant_message" as const, - text: longText, - streaming: false, - ordinal: 999, - }; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("event:oversized-retrieval"), - type: "turn-item.updated", - threadId: directChildThreadId, - occurredAt: yield* DateTime.now, - payload: oversizedItem, - }, - ], - }); - let recovered = ""; - let textOffset: number | null = 0; - while (textOffset !== null) { - const pageCall = yield* invoke("t3_thread_read", { - threadId: directChildThreadId, - itemId: oversizedItem.id, - textOffset, - maxCharsPerItem: 50_000, - }); - const page: OrchestratorMcpThreadReadResult = yield* decodeThreadReadResult( - pageCall.structuredContent, - ).pipe(Effect.orDie); - expect(page.items).toHaveLength(1); - recovered += page.items[0]!.text; - textOffset = page.items[0]!.nextTextOffset ?? null; - } - expect(recovered).toBe(longText); - - const acknowledgedByDirectRead = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === directRead.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - expect( - acknowledgedByDirectRead.subagents.find((task) => task.id === directRead.task.id) - ?.completionDelivery, - ).toMatchObject({ state: "acknowledged", observedByRunId: parentRun.id }); - yield* Ref.set(continuationOffers, []); - - const queueRace = yield* queueAutomaticCompletion( - "queue-race", - "Complete before a user queues follow-up work.", - ); - const queuedUserMessageId = MessageId.make("message:mcp-parent:queue-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:queue-race:user"), - threadId: parentThreadId, - messageId: queuedUserMessageId, - text: "🙂".repeat(16001), - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - }); - const queueRaceQueued = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some((run) => run.id === queueRace.queuedRun.id) && - projection.runs.some( - (run) => run.userMessageId === queuedUserMessageId && run.status === "queued", - ), - ); - const queuedUserRun = queueRaceQueued.runs.find( - (run) => run.userMessageId === queuedUserMessageId, - ); - if (queuedUserRun === undefined) { - return yield* Effect.die(new Error("Queued user follow-up missing.")); - } - const queueFirstPage = yield* invoke("t3_queue_list", { limit: 1 }); - expect(queueFirstPage.isError).toBe(false); - expect(queueFirstPage.structuredContent).toMatchObject({ - items: [{ queuedRunId: queueRace.queuedRun.id }], - nextCursor: 1, - }); - const queueDefinition = server.tools.find(({ tool }) => tool.name === "t3_queue_list"); - const validateQueue = new AjvJsonSchemaValidator().getValidator( - queueDefinition!.tool.outputSchema! as JsonSchemaType, - ); - expect(validateQueue(queueFirstPage.structuredContent).valid).toBe(true); - const missingThreadId = ThreadId.make("00000000-0000-4000-8000-000000000000"); - const missingThreadQueue = yield* invoke("t3_queue_list", { - threadId: missingThreadId, - limit: 1, - }); - expect(missingThreadQueue.isError).toBe(true); - expect(declaredFailure(missingThreadQueue)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "thread_not_found", - message: "The thread was not found.", - }); - expect(missingThreadQueue.structuredContent).toBeUndefined(); - expect(validateQueue({ items: "invalid", nextCursor: null }).valid).toBe(false); - const missingThreadRead = yield* invoke("t3_thread_read", { - threadId: missingThreadId, - }); - expect(missingThreadRead.isError).toBe(true); - expect(declaredFailure(missingThreadRead)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - }); - const queueSecondPage = yield* invoke("t3_queue_list", { cursor: 1, limit: 1 }); - expect(queueSecondPage.structuredContent).toEqual({ - items: [{ queuedRunId: queuedUserRun.id, text: "🙂".repeat(1000), truncated: true }], - nextCursor: null, - }); - const queueRead = yield* invoke("t3_queue_read", { queuedRunId: queuedUserRun.id }); - expect(queueRead.structuredContent).toEqual({ - queuedRunId: queuedUserRun.id, - text: "🙂".repeat(16000), - truncated: true, - }); - const missingQueueRead = yield* invoke("t3_queue_read", { queuedRunId: parentRun.id }); - expect(declaredFailure(missingQueueRead)).toMatchObject({ code: "invalid_request" }); - const queueRaceStatus = yield* invoke("task_status", { taskId: queueRace.task.id }); - expect(queueRaceStatus.isError).toBe(false); - yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.find((run) => run.id === queueRace.queuedRun.id)?.status === - "cancelled" && - projection.runs.find((run) => run.id === queuedUserRun.id)?.status === "queued" && - projection.subagents.find((task) => task.id === queueRace.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - yield* orchestrator.dispatch({ - type: "queued-run.cancel", - commandId: CommandId.make("command:mcp-parent:queue-race:cleanup"), - threadId: parentThreadId, - runId: queuedUserRun.id, - }); - - const steerRace = yield* queueAutomaticCompletion( - "steer-race", - "Complete before a user steers the parent.", - ); - const steerMessageId = MessageId.make("message:mcp-parent:steer-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:steer-race:user"), - threadId: parentThreadId, - messageId: steerMessageId, - text: "Steer the active parent after receiving the result.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "steer_active", targetRunId: parentRun.id }, - }); - const steerRaceStatus = yield* invoke("task_status", { taskId: steerRace.task.id }); - expect(steerRaceStatus.isError).toBe(false); - const acknowledgedSteerRace = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.messages.some((message) => message.id === steerMessageId) && - projection.runs.find((run) => run.id === parentRun.id)?.status === "running" && - projection.runs.find((run) => run.id === steerRace.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === steerRace.task.id) - ?.completionDelivery?.state === "acknowledged", - ); - expect( - acknowledgedSteerRace.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery, - ).toBeNull(); - - const restartRace = yield* queueAutomaticCompletion( - "restart-race", - "Complete before a user restarts the parent.", - ); - const attemptBeforeRestart = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).runs.find((run) => run.id === parentRun.id)?.activeAttemptId; - const restartMessageId = MessageId.make("message:mcp-parent:restart-race:user"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-parent:restart-race:user"), - threadId: parentThreadId, - messageId: restartMessageId, - text: "Restart the active parent after receiving the result.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "restart_active", targetRunId: parentRun.id }, - }); - const restartedParent = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => { - const run = projection.runs.find((candidate) => candidate.id === parentRun.id); - return ( - run?.status === "running" && - run.activeAttemptId !== attemptBeforeRestart && - run.rootNodeId !== null - ); - }, - ); - const currentParentRun = restartedParent.runs.find((run) => run.id === parentRun.id); - if (currentParentRun?.rootNodeId === null || currentParentRun === undefined) { - return yield* Effect.die(new Error("Restarted parent run missing.")); - } - parentRootNodeId = currentParentRun.rootNodeId; - expect(currentParentRun.delegatedCompletion?.disposition).toBe("open"); - const restartRaceStatus = yield* invoke("task_status", { - taskId: restartRace.task.id, - }); - expect(restartRaceStatus.isError).toBe(false); - yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.messages.some((message) => message.id === restartMessageId) && - projection.runs.find((run) => run.id === restartRace.queuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === restartRace.task.id) - ?.completionDelivery?.state === "acknowledged" && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.disposition === "open", - ); - yield* Ref.set(continuationOffers, []); - - const capabilitiesTool = server.tools.find( - ({ tool }) => tool.name === "orchestrator_capabilities", - ); - expect(capabilitiesTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(capabilitiesTool?.tool.annotations?.idempotentHint).toBe(true); - const delegateTool = server.tools.find(({ tool }) => tool.name === "delegate_task"); - expect(delegateTool?.tool.annotations?.destructiveHint).toBe(true); - expect(delegateTool?.tool.annotations?.openWorldHint).toBe(true); - const taskStatusTool = server.tools.find(({ tool }) => tool.name === "task_status"); - expect(taskStatusTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(taskStatusTool?.tool.annotations?.idempotentHint).toBe(true); - const createThreadsTool = server.tools.find( - ({ tool }) => tool.name === "create_threads", - ); - expect(createThreadsTool?.tool.annotations?.destructiveHint).toBe(true); - const threadListTool = server.tools.find(({ tool }) => tool.name === "t3_thread_list"); - expect(threadListTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(threadListTool?.tool.annotations?.idempotentHint).toBe(true); - const threadReadTool = server.tools.find(({ tool }) => tool.name === "t3_thread_read"); - expect(threadReadTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(threadReadTool?.tool.inputSchema).toMatchObject({ additionalProperties: false }); - const unknownPagingParamError = yield* invoke("t3_thread_read", { - threadId: parentThreadId, - cursor: 90, - }).pipe(Effect.flip); - expect(unknownPagingParamError.message).toContain("Expected no excess property"); - const threadUpdateTool = server.tools.find( - ({ tool }) => tool.name === "t3_thread_update", - ); - expect(threadUpdateTool?.tool.annotations?.destructiveHint).toBe(true); - expect(threadUpdateTool?.tool.annotations?.idempotentHint).toBe(false); - expect(threadUpdateTool?.tool.inputSchema).toMatchObject({ - type: "object", - properties: { - action: expect.any(Object), - title: expect.any(Object), - pullRequest: expect.any(Object), - }, - }); - const deniedThreadUpdate = yield* invokeAs( - { ...invocation, capabilities: new Set() }, - "t3_thread_update", - { action: "rename", title: "Denied title" }, - ); - expect(declaredFailure(deniedThreadUpdate)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "capability_denied", - }); - const threadSendTool = server.tools.find(({ tool }) => tool.name === "t3_thread_send"); - expect(threadSendTool?.tool.annotations?.destructiveHint).toBe(true); - const threadWaitTool = server.tools.find(({ tool }) => tool.name === "t3_thread_wait"); - expect(threadWaitTool?.tool.annotations?.readOnlyHint).toBe(true); - const threadInterruptTool = server.tools.find( - ({ tool }) => tool.name === "t3_thread_interrupt", - ); - expect(threadInterruptTool?.tool.annotations?.destructiveHint).toBe(true); - - const capabilities = yield* invoke("orchestrator_capabilities", {}); - expect(capabilities.isError).toBe(false); - expect(capabilities.structuredContent).toMatchObject({ - inheritedProviderInstanceId: codexInstanceId, - inheritedModel: codexModel, - features: { - appOwnedSubagents: true, - asyncPolling: true, - cancellation: true, - batchThreadCreation: true, - threadManagement: true, - incrementalThreadRead: true, - }, - providers: expect.arrayContaining([ - expect.objectContaining({ - providerInstanceId: claudeInstanceId, - canRunCrossProviderChildTask: true, - }), - expect.objectContaining({ - providerInstanceId: "opencode", - canRunChildTask: false, - }), - expect.objectContaining({ - providerInstanceId: codexInstanceId, - models: [ - expect.objectContaining({ - id: codexModel, - options: [expect.objectContaining({ id: "reasoning", type: "select" })], - }), - ], - }), - ]), - }); - - const scheduleTool = server.tools.find(({ tool }) => tool.name === "schedule_task"); - expect(scheduleTool?.tool.annotations?.destructiveHint).toBe(true); - const scheduleCall = yield* invoke("schedule_task", { - prompt: "wake up in this thread and say hello", - schedule: { type: "interval", everyMs: 60_000 }, - clientRequestId: "schedule-hello-1", - }); - expect(scheduleCall.isError).toBe(false); - expect(scheduleCall.structuredContent).toMatchObject({ - boundThreadId: parentThreadId, - projectId, - enabled: true, - schedule: { type: "interval", everyMs: 60_000 }, - title: "wake up in this thread and say hello", - }); - const scheduledTaskId = (scheduleCall.structuredContent as { scheduledTaskId: string }) - .scheduledTaskId; - const storedAfterCreate = yield* Ref.get(scheduledStore); - expect(storedAfterCreate).toHaveLength(1); - expect(storedAfterCreate[0]).toMatchObject({ - threadId: parentThreadId, - projectId, - createdBy: "agent", - creationSource: "mcp", - modelSelection: codexSelection, - }); - - const scheduledListCall = yield* invoke("list_scheduled_tasks", {}); - expect(scheduledListCall.isError).toBe(false); - expect(scheduledListCall.structuredContent).toMatchObject({ - tasks: [{ scheduledTaskId, boundThreadId: parentThreadId }], - }); - - const scheduledUpdateCall = yield* invoke("update_scheduled_task", { - scheduledTaskId, - enabled: false, - }); - expect(scheduledUpdateCall.isError).toBe(false); - expect(scheduledUpdateCall.structuredContent).toMatchObject({ - scheduledTaskId, - enabled: false, - }); - - const scheduledDeleteCall = yield* invoke("delete_scheduled_task", { scheduledTaskId }); - expect(scheduledDeleteCall.isError).toBe(false); - expect(scheduledDeleteCall.structuredContent).toMatchObject({ - scheduledTaskId, - deleted: true, - }); - expect(yield* Ref.get(scheduledStore)).toHaveLength(0); - - // OpenCode 1.15 has emitted this exact nested-object-as-JSON-string - // shape. Decode it at the MCP boundary rather than failing a task - // the model otherwise specified correctly. - const serializedScheduleCall = yield* invoke("schedule_task", { - prompt: "check for new pull requests", - schedule: '{"type":"interval","everyMs":3600000}', - clientRequestId: "schedule-opencode-compat-1", - }); - expect(serializedScheduleCall.isError).toBe(false); - expect(serializedScheduleCall.structuredContent).toMatchObject({ - schedule: { type: "interval", everyMs: 3_600_000 }, - boundThreadId: parentThreadId, - }); - const serializedScheduledTaskId = ( - serializedScheduleCall.structuredContent as { scheduledTaskId: string } - ).scheduledTaskId; - yield* invoke("delete_scheduled_task", { - scheduledTaskId: serializedScheduledTaskId, - }); - expect(yield* Ref.get(scheduledStore)).toHaveLength(0); - - const secretRequests = yield* SecretRequests.SecretRequests; - const secretFiber = yield* invoke("request_secret", { - label: "GitHub webhook secret", - reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.", - placeholder: "Paste the webhook secret", - clientRequestId: "release-webhook-secret", - }).pipe(Effect.forkChild); - const asked = yield* Effect.gen(function* () { - while (true) { - const projection = yield* orchestrator.getThreadProjection(parentThreadId); - if ( - projection.turnItems.some( - (item) => item.type === "secret_request" && item.secretStatus === "pending", - ) - ) { - return projection; - } - yield* Effect.sleep("5 millis"); - } - }); - const card = asked.turnItems.find((item) => item.type === "secret_request"); - if (card?.type !== "secret_request") { - return yield* Effect.die(new Error("Secret request card missing.")); - } - expect(card).toMatchObject({ - label: "GitHub webhook secret", - placeholder: "Paste the webhook secret", - }); - yield* orchestrator.dispatch({ - type: "secret_request.record", - commandId: CommandId.make("command:test:secret-request-replay"), - threadId: parentThreadId, - runId: card.runId!, - nodeId: card.nodeId!, - turnItemId: card.id, - label: "Something else", - reason: "A different reason.", - secretStatus: "pending", - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.find( - (item) => item.id === card.id, - ), - ).toMatchObject({ label: "GitHub webhook secret", runId: card.runId }); - expect( - (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest, - ).toMatchObject({ kind: "user_input" }); - expect(threadShellFromProjection(asked).pendingRuntimeRequest).toMatchObject({ - kind: "user_input", - }); - yield* secretRequests.answer({ - threadId: parentThreadId, - turnItemId: card.id, - answer: { type: "save", secret: "github-webhook-secret" }, - }); - const secretCall = yield* Fiber.join(secretFiber); - expect(secretCall.isError).toBe(false); - const secretResult = secretCall.structuredContent as { - status: string; - secretRef?: string; - }; - expect(secretResult.status).toBe("saved"); - expect( - (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest ?? null, - ).toBeNull(); - expect(secretResult.secretRef).toMatch(/^secret-ref:[0-9a-f]{32}$/); - const received = [ - ...secretCall.content.map((part) => ("text" in part ? part.text : "")), - ...Object.values(secretResult), - ]; - expect(received.some((value) => value.includes("github-webhook-secret"))).toBe(false); - - const retried = yield* invoke("request_secret", { - label: "GitHub webhook secret", - reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.", - clientRequestId: "release-webhook-secret", - }); - expect(retried.structuredContent).toEqual(secretResult); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).turnItems.filter( - (item) => item.type === "secret_request", - ), - ).toHaveLength(1); - - expect( - yield* secretRequests.consume({ - ref: secretResult.secretRef as never, - projectId, - }), - ).toBe("github-webhook-secret"); - const reused = yield* secretRequests - .consume({ ref: secretResult.secretRef as never, projectId }) - .pipe(Effect.flip); - expect(reused.message).toContain("already used"); - - const delegatedOptions = [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]; - const delegatedCall = yield* invoke("delegate_task", { - task: delegatedPrompt, - target: { - providerInstanceId: claudeInstanceId, - model: claudeModel, - options: { effort: "max", fastMode: true }, - }, - mode: "wait", - timeoutMs: 10_000, - clientRequestId: "delegate-claude-1", - }); - expect(delegatedCall.isError).toBe(false); - const delegated = yield* decodeDelegateTaskResult(delegatedCall.structuredContent).pipe( - Effect.orDie, - ); - const delegatedSource = yield* orchestrator.getThreadProjection( - delegated.childThreadId, - ); - expect(delegatedSource.messages[0]).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - delegatedSource.turnItems.find((item) => item.type === "user_message"), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect(delegated.status).toBe("completed"); - expect(delegated.summary).toBe(delegatedResult); - expect(delegated.providerInstanceId).toBe(claudeInstanceId); - expect(delegated.options).toEqual(delegatedOptions); - - const completedParent = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.subagents.some( - (task) => - task.id === delegated.taskId && - task.status === "completed" && - task.result === delegatedResult, - ) && - projection.contextTransfers.some( - (transfer) => - transfer.type === "subagent_result" && - transfer.sourceThreadId === delegated.childThreadId, - ), - ); - const completedTask = completedParent.subagents.find( - (task) => task.id === delegated.taskId, - ); - expect(completedTask).toMatchObject({ - origin: "app_owned", - createdBy: "agent", - childThreadId: delegated.childThreadId, - status: "completed", - result: delegatedResult, - }); - const child = yield* orchestrator.getThreadProjection(delegated.childThreadId); - expect(child.thread.lineage).toEqual({ - parentThreadId, - relationshipToParent: "subagent", - rootThreadId: parentThreadId, - }); - expect(child.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(child.thread.modelSelection).toEqual({ - ...claudeSelection, - options: delegatedOptions, - }); - expect( - child.messages - .filter((message) => message.role === "user") - .map((message) => message.text), - ).toEqual([delegatedPrompt]); - expect( - child.contextTransfers.some( - (transfer) => - transfer.type === "subagent_spawn" && transfer.sourceThreadId === parentThreadId, - ), - ).toBe(true); - const capturedAfterDelegate = yield* Ref.get(capturedTurns); - expect( - capturedAfterDelegate.filter((turn) => turn.threadId === delegated.childThreadId), - ).toEqual([ - { - instanceId: claudeInstanceId, - threadId: delegated.childThreadId, - text: delegatedPrompt, - modelSelection: { ...claudeSelection, options: delegatedOptions }, - }, - ]); - expect( - capturedAfterDelegate.some( - (turn) => - turn.threadId === delegated.childThreadId && turn.text.includes(parentPrompt), - ), - ).toBe(false); - - const delegatedStatusCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatus = yield* decodeDelegateTaskResult( - delegatedStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatus).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - hasPendingChildRuns: false, - latestTerminalRunId: delegated.childRunId, - latestTerminalStatus: "completed", - latestTerminalSummary: null, - latestTerminalResultContextTransferId: null, - }); - expect(delegatedStatus.resultContextTransferId).not.toBeNull(); - - yield* expectOffersToStay(0); - - const childFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: "Confirm the delegated API boundary remains inspected.", - clientRequestId: "delegated-child-followup-1", - }); - const childFollowup = yield* decodeThreadSendResult( - childFollowupCall.structuredContent, - ).pipe(Effect.orDie); - const childFollowupWaitCall = yield* invoke("t3_thread_wait", { - threadId: delegated.childThreadId, - runId: childFollowup.runId, - timeoutMs: 10_000, - }); - const childFollowupWait = yield* decodeThreadWaitResult( - childFollowupWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(childFollowupWait).toMatchObject({ - runId: childFollowup.runId, - status: "completed", - timedOut: false, - }); - const delegatedStatusAfterFollowupCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusAfterFollowup = yield* decodeDelegateTaskResult( - delegatedStatusAfterFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusAfterFollowup).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: false, - latestTerminalRunId: childFollowup.runId, - latestTerminalStatus: "completed", - }); - expect(delegatedStatusAfterFollowup.latestTerminalSummary).not.toBeNull(); - - const activeChildFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: cancellationPrompt, - clientRequestId: "delegated-child-active-followup-1", - }); - const activeChildFollowup = yield* decodeThreadSendResult( - activeChildFollowupCall.structuredContent, - ).pipe(Effect.orDie); - yield* waitForProjection(orchestrator, delegated.childThreadId, (projection) => - projection.runs.some( - (run) => run.id === activeChildFollowup.runId && run.status === "running", - ), - ); - const delegatedStatusDuringFollowupCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusDuringFollowup = yield* decodeDelegateTaskResult( - delegatedStatusDuringFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusDuringFollowup).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: true, - latestTerminalRunId: childFollowup.runId, - latestTerminalStatus: "completed", - }); - const activeChildProjection = yield* orchestrator.getThreadProjection( - delegated.childThreadId, - ); - const legacyChildProjection = { - ...activeChildProjection, - contextTransfers: activeChildProjection.contextTransfers.filter( - (transfer) => transfer.type !== "subagent_spawn", - ), - }; - const legacyDelegatedRun = delegatedTaskRun(legacyChildProjection, completedTask!); - expect(legacyDelegatedRun?.id).toBe(delegated.childRunId); - expect(hasPendingChildRuns(legacyChildProjection, legacyDelegatedRun)).toBe(true); - expect( - hasPendingChildRuns( - { - ...legacyChildProjection, - runs: legacyChildProjection.runs.map((run) => - run.id === activeChildFollowup.runId ? { ...run, status: "queued" } : run, - ), - }, - legacyDelegatedRun, - ), - ).toBe(true); - expect( - hasPendingChildRuns( - { - ...legacyChildProjection, - runs: legacyChildProjection.runs.map((run) => - run.id === activeChildFollowup.runId - ? { ...run, status: "queued", queueHeld: true } - : run, - ), - }, - legacyDelegatedRun, - ), - ).toBe(false); - const heldFollowupCall = yield* invoke("t3_thread_send", { - threadId: delegated.childThreadId, - message: "This queued follow-up must remain stopped until resumed.", - mode: "queue", - clientRequestId: "delegated-child-held-followup", - }); - const heldFollowup = yield* decodeThreadSendResult( - heldFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(heldFollowup.taskId).toBeDefined(); - const completedTaskCancelCall = yield* invoke("task_cancel", { - taskId: delegated.taskId, - reason: "Stop the child's later work too.", - clientRequestId: "cancel-completed-delegated-task-1", - }); - const completedTaskCancel = yield* decodeTaskCancelResult( - completedTaskCancelCall.structuredContent, - ).pipe(Effect.orDie); - expect(completedTaskCancel).toEqual({ - taskId: delegated.taskId, - status: "completed", - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === delegated.taskId, - ), - ).toMatchObject({ - result: delegatedResult, - completionDelivery: { state: "disposed" }, - }); - yield* waitForProjection(orchestrator, delegated.childThreadId, (projection) => - projection.runs.some( - (run) => run.id === activeChildFollowup.runId && run.status === "interrupted", - ), - ); - const delegatedStatusAfterCancelCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const delegatedStatusAfterCancel = yield* decodeDelegateTaskResult( - delegatedStatusAfterCancelCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStatusAfterCancel).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - hasPendingChildRuns: false, - latestTerminalRunId: activeChildFollowup.runId, - latestTerminalStatus: "interrupted", - }); - - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === heldFollowup.taskId && task.status === "idle", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( - (run) => run.id === heldFollowup.runId, - ), - ).toMatchObject({ status: "queued", queueHeld: true, startedAt: null }); - const cancelHeldCall = yield* invoke("task_cancel", { - taskId: heldFollowup.taskId, - clientRequestId: "cancel-held-delegated-followup", - }); - expect( - (yield* decodeTaskCancelResult(cancelHeldCall.structuredContent).pipe(Effect.orDie)) - .status, - ).toBe("cancel_requested"); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === heldFollowup.taskId && task.status === "cancelled", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find( - (run) => run.id === heldFollowup.runId, - ), - ).toMatchObject({ status: "cancelled", startedAt: null }); - - expect(childFollowup.taskId).toBeDefined(); - expect(activeChildFollowup.taskId).toBeDefined(); - yield* waitForContinuationOffers(2); - yield* expectOffersToStay(2); - yield* invoke("task_status", { taskId: childFollowup.taskId }); - yield* invoke("task_status", { taskId: activeChildFollowup.taskId }); - yield* Ref.set(continuationOffers, []); - - // A send into a child run the server started has no task to join. - // It gets its own task only when it starts a new attempt; a steer - // into the running attempt leaves no run that could settle one. - const childSteerText = "Include the parent's latest guidance before finishing."; - const steerIntoUntaskedChildRun = ( - target: { readonly providerInstanceId: ProviderInstanceId; readonly model: string }, - name: string, - ) => - Effect.gen(function* () { - const delegateCall = yield* invoke("delegate_task", { - task: "Inspect the steering boundary and return the result.", - target, - mode: "wait", - timeoutMs: 10_000, - clientRequestId: `${name}-delegate`, - }); - const child = yield* decodeDelegateTaskResult(delegateCall.structuredContent).pipe( - Effect.orDie, - ); - expect(child.status).toBe("completed"); - const messageId = MessageId.make(`message:mcp-child:${name}`); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make(`command:mcp-child:${name}`), - threadId: child.childThreadId, - messageId, - text: cancellationPrompt, - attachments: [], - modelSelection: { instanceId: target.providerInstanceId, model: target.model }, - dispatchMode: { type: "start_immediately" }, - }); - const running = yield* waitForProjection( - orchestrator, - child.childThreadId, - (projection) => { - const run = projection.runs.find((row) => row.userMessageId === messageId); - return ( - run?.status === "running" && - projection.providerTurns.some( - (turn) => turn.nodeId === run.rootNodeId && turn.status === "running", - ) - ); - }, - ); - const run = running.runs.find((row) => row.userMessageId === messageId)!; - expect(run.delegatedTaskId).toBeUndefined(); - const sendCall = yield* invoke("t3_thread_send", { - threadId: child.childThreadId, - message: childSteerText, - mode: "steer", - clientRequestId: `${name}-steer`, - }); - const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe( - Effect.orDie, - ); - expect(sent).toMatchObject({ runId: run.id, delivery: "steered" }); - return { child, sent }; - }); - const waitForChildRun = ( - childThreadId: ThreadId, - runId: string, - status: "completed" | "interrupted", - ) => - waitForProjection(orchestrator, childThreadId, (projection) => - projection.runs.some((run) => run.id === runId && run.status === status), - ); - - // Claude steers into the running turn, which then ends. - const steerReceived = yield* Deferred.make(); - steerReceivedGates.set(childSteerText, steerReceived); - const steeredSend = yield* steerIntoUntaskedChildRun( - { providerInstanceId: claudeInstanceId, model: claudeModel }, - "steer-untasked-run", - ); - // A steer that misses the turn is sent again as its own run, so - // end the turn only once the steer reached it. - yield* Deferred.await(steerReceived); - steerReceivedGates.delete(childSteerText); - yield* invoke("t3_thread_interrupt", { - threadId: steeredSend.child.childThreadId, - runId: steeredSend.sent.runId, - clientRequestId: "steer-untasked-run-interrupt", - }); - yield* waitForChildRun( - steeredSend.child.childThreadId, - steeredSend.sent.runId, - "interrupted", - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter( - (task) => - task.origin === "app_owned" && - task.childThreadId === steeredSend.child.childThreadId && - task.status === "running", - ), - ).toEqual([]); - expect(steeredSend.sent.taskId).toBeUndefined(); - - // This Codex session has active steering off, so the send restarts the - // turn as a new attempt that owns the follow-up task and settles it. - const restarted = yield* steerIntoUntaskedChildRun( - { providerInstanceId: codexInstanceId, model: codexModel }, - "restart-untasked-run", - ); - const restartedTaskId = restarted.sent.taskId; - expect(restartedTaskId).toBeDefined(); - yield* waitForChildRun( - restarted.child.childThreadId, - restarted.sent.runId, - "completed", - ); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === restartedTaskId && task.status === "completed", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(restarted.child.childThreadId)).runs.find( - (run) => run.id === restarted.sent.runId, - )?.delegatedTaskId, - ).toBe(restartedTaskId); - yield* invoke("task_status", { taskId: steeredSend.child.taskId }); - yield* invoke("task_status", { taskId: restarted.child.taskId }); - yield* invoke("task_status", { taskId: restartedTaskId }); - yield* Ref.set(continuationOffers, []); - - const repeatedDelegatedCall = yield* invoke("delegate_task", { - task: delegatedPrompt, - target: { - providerInstanceId: claudeInstanceId, - model: claudeModel, - }, - mode: "async", - clientRequestId: "delegate-claude-1", - }); - const repeatedDelegated = yield* decodeDelegateTaskResult( - repeatedDelegatedCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedDelegated.taskId).toBe(delegated.taskId); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter( - (task) => task.id === delegated.taskId, - ), - ).toHaveLength(1); - - const rejectedOptionsCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: { reasoning: "extreme" }, - }, - mode: "async", - clientRequestId: "delegate-rejected-options-1", - }); - expect(declaredFailure(rejectedOptionsCall)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "invalid_request", - message: expect.stringContaining("rejected options"), - }); - - const duplicateOptionsCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: [ - { id: "reasoning", value: "low" }, - { id: "reasoning", value: "high" }, - ], - }, - mode: "async", - clientRequestId: "delegate-duplicate-options-1", - }); - expect(declaredFailure(duplicateOptionsCall)).toMatchObject({ - _tag: "OrchestratorMcpFailure", - code: "invalid_request", - message: expect.stringContaining("more than once"), - }); - - const cancellableCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - options: { reasoning: "low" }, - }, - mode: "async", - clientRequestId: "delegate-cancel-1", - }); - const cancellable = yield* decodeDelegateTaskResult( - cancellableCall.structuredContent, - ).pipe(Effect.orDie); - expect(cancellable.status).toBe("running"); - expect(cancellable.hasPendingChildRuns).toBe(false); - yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const statusWhileOriginalRunningCall = yield* invoke("task_status", { - taskId: cancellable.taskId, - }); - const statusWhileOriginalRunning = yield* decodeDelegateTaskResult( - statusWhileOriginalRunningCall.structuredContent, - ).pipe(Effect.orDie); - expect(statusWhileOriginalRunning).toMatchObject({ - childRunId: cancellable.childRunId, - status: "running", - hasPendingChildRuns: false, - latestTerminalRunId: null, - latestTerminalStatus: null, - }); - const optionedChild = yield* orchestrator.getThreadProjection( - cancellable.childThreadId, - ); - expect(optionedChild.thread.modelSelection).toEqual({ - instanceId: codexInstanceId, - model: codexModel, - options: [{ id: "reasoning", value: "low" }], - }); - const cancelCall = yield* invoke("task_cancel", { - taskId: cancellable.taskId, - reason: "Parent no longer needs this work.", - clientRequestId: "cancel-1", - }); - const cancelResult = yield* decodeTaskCancelResult(cancelCall.structuredContent).pipe( - Effect.orDie, - ); - expect(cancelResult.status).toBe("cancel_requested"); - yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => - projection.runs.some((run) => run.status === "interrupted"), - ); - const cancelledStatusCall = yield* invoke("task_status", { - taskId: cancellable.taskId, - }); - const cancelledStatus = yield* decodeDelegateTaskResult( - cancelledStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(cancelledStatus.status).toBe("interrupted"); - - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === cancellable.taskId, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - yield* expectOffersToStay(0); - - const createInput = { - clientRequestId: "create-thread-batch-1", - threads: [ - { - title: "Inherited empty thread", - }, - { - title: "Claude ordinary thread", - prompt: createdThreadPrompt, - target: { - driverKind: "claudeAgent", - }, - }, - ], - }; - const createCall = yield* invoke("create_threads", createInput); - expect(createCall.isError).toBe(false); - const created = yield* decodeCreateThreadsResult(createCall.structuredContent).pipe( - Effect.orDie, - ); - expect(created.threads).toHaveLength(2); - const emptyThread = created.threads[0]!; - const promptedThread = created.threads[1]!; - expect(emptyThread).toMatchObject({ - status: "idle", - createdBy: "agent", - creationSource: "mcp", - providerInstanceId: codexInstanceId, - model: codexModel, - }); - expect(promptedThread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - providerInstanceId: claudeInstanceId, - model: claudeModel, - }); - const createdSource = yield* orchestrator.getThreadProjection(promptedThread.threadId); - expect(createdSource.messages[0]).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - createdSource.turnItems.find((item) => item.type === "user_message"), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - const emptyProjection = yield* orchestrator.getThreadProjection(emptyThread.threadId); - expect(emptyProjection.thread.lineage).toEqual({ - parentThreadId: null, - relationshipToParent: null, - rootThreadId: emptyThread.threadId, - }); - expect(emptyProjection.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(emptyProjection.thread.forkedFrom).toBeNull(); - expect(emptyProjection.runs).toEqual([]); - - const defaultRenameCall = yield* invoke("t3_thread_update", { - action: "rename", - title: "MCP parent metadata", - clientRequestId: "metadata-default-thread-1", - }); - const defaultRenamed = yield* decodeThreadUpdateResult( - defaultRenameCall.structuredContent, - ).pipe(Effect.orDie); - expect(defaultRenamed).toMatchObject({ - threadId: parentThreadId, - action: "rename", - title: "MCP parent metadata", - }); - - const renameCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - clientRequestId: "metadata-rename-1", - }); - const renamed = yield* decodeThreadUpdateResult(renameCall.structuredContent).pipe( - Effect.orDie, - ); - expect(renamed).toMatchObject({ - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - linkedPullRequest: null, - }); - const repeatedRenameCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "rename", - title: "Metadata-managed thread", - clientRequestId: "metadata-rename-1", - }); - const repeatedRename = yield* decodeThreadUpdateResult( - repeatedRenameCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedRename.commandId).toBe(renamed.commandId); - expect(repeatedRename.sequence).toBe(renamed.sequence); - - const linkedCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "link_pull_request", - pullRequest: { - repository: "pingdotgg/t3code", - number: 8689, - url: "https://github.com/pingdotgg/t3code/pull/8689", - }, - clientRequestId: "metadata-link-1", - }); - const linked = yield* decodeThreadUpdateResult(linkedCall.structuredContent).pipe( - Effect.orDie, - ); - expect(linked.linkedPullRequest).toEqual({ - projectId, - repository: "pingdotgg/t3code", - number: 8689, - url: "https://github.com/pingdotgg/t3code/pull/8689", - }); - const metadataReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const metadataRead = yield* decodeThreadReadResult( - metadataReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(metadataRead.thread).toMatchObject({ - title: "Metadata-managed thread", - linkedPullRequest: linked.linkedPullRequest, - }); - const metadataListCall = yield* invoke("t3_thread_list", { limit: 100 }); - const metadataList = yield* decodeThreadListResult( - metadataListCall.structuredContent, - ).pipe(Effect.orDie); - expect( - metadataList.threads.find((thread) => thread.threadId === emptyThread.threadId), - ).toMatchObject({ - title: "Metadata-managed thread", - linkedPullRequest: linked.linkedPullRequest, - settled: false, - settledAt: null, - }); - expect(metadataRead.thread).toMatchObject({ settled: false, settledAt: null }); - - yield* orchestrator.dispatch({ - type: "thread.settle", - commandId: CommandId.make("command:mcp-empty:settle"), - threadId: emptyThread.threadId, - settledAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), - }); - const settledReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const settledRead = yield* decodeThreadReadResult( - settledReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(settledRead.thread).toMatchObject({ - settled: true, - settledAt: "2026-01-01T00:00:00.000Z", - }); - const settledListCall = yield* invoke("t3_thread_list", { settled: true, limit: 100 }); - const settledList = yield* decodeThreadListResult( - settledListCall.structuredContent, - ).pipe(Effect.orDie); - expect(settledList.threads.map((thread) => thread.threadId)).toEqual([ - emptyThread.threadId, - ]); - expect(settledList.threads[0]).toMatchObject({ - settled: true, - settledAt: "2026-01-01T00:00:00.000Z", - }); - const activeListCall = yield* invoke("t3_thread_list", { settled: false, limit: 100 }); - const activeList = yield* decodeThreadListResult(activeListCall.structuredContent).pipe( - Effect.orDie, - ); - expect( - activeList.threads.some((thread) => thread.threadId === emptyThread.threadId), - ).toBe(false); - yield* orchestrator.dispatch({ - type: "thread.unsettle", - commandId: CommandId.make("command:mcp-empty:unsettle"), - threadId: emptyThread.threadId, - reason: "user", - }); - - expect(metadataRead.thread).toMatchObject({ snoozed: false, snoozedUntil: null }); - yield* orchestrator.dispatch({ - type: "thread.snooze", - commandId: CommandId.make("command:mcp-empty:snooze"), - threadId: emptyThread.threadId, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - const snoozedListCall = yield* invoke("t3_thread_list", { snoozed: true, limit: 100 }); - const snoozedList = yield* decodeThreadListResult( - snoozedListCall.structuredContent, - ).pipe(Effect.orDie); - expect(snoozedList.threads.map((thread) => thread.threadId)).toEqual([ - emptyThread.threadId, - ]); - expect(snoozedList.threads[0]).toMatchObject({ - snoozed: true, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - const snoozedReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const snoozedRead = yield* decodeThreadReadResult( - snoozedReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(snoozedRead.thread).toMatchObject({ - snoozed: true, - snoozedUntil: "2099-01-01T00:00:00.000Z", - }); - yield* orchestrator.dispatch({ - type: "thread.unsnooze", - commandId: CommandId.make("command:mcp-empty:unsnooze"), - threadId: emptyThread.threadId, - reason: "user", - }); - - const unlinkedCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "unlink_pull_request", - clientRequestId: "metadata-unlink-1", - }); - const unlinked = yield* decodeThreadUpdateResult(unlinkedCall.structuredContent).pipe( - Effect.orDie, - ); - expect(unlinked.linkedPullRequest).toBeNull(); - - // Links added through thread.pull-request.link leave linkedPullRequest unchanged. - yield* orchestrator.dispatch({ - type: "thread.pull-request.link", - commandId: CommandId.make("command:mcp-empty:link-other-repository"), - threadId: emptyThread.threadId, - host: "github.com", - repository: "octo/other-repo", - number: 42, - url: "https://github.com/octo/other-repo/pull/42", - source: "agent", - }); - yield* orchestrator.dispatch({ - type: "thread.pull-request.link", - commandId: CommandId.make("command:mcp-empty:link-dismissed"), - threadId: emptyThread.threadId, - host: "github.com", - repository: "octo/other-repo", - number: 43, - url: "https://github.com/octo/other-repo/pull/43", - source: "stack-dismissed", - }); - const otherRepositoryLink = { - host: "github.com", - repository: "octo/other-repo", - number: 42, - url: "https://github.com/octo/other-repo/pull/42", - source: "agent", - state: null, - }; - const otherRepositoryReadCall = yield* invoke("t3_thread_read", { - threadId: emptyThread.threadId, - }); - const otherRepositoryRead = yield* decodeThreadReadResult( - otherRepositoryReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(otherRepositoryRead.thread).toMatchObject({ - linkedPullRequest: null, - pullRequests: [otherRepositoryLink], - }); - const otherRepositoryListCall = yield* invoke("t3_thread_list", { limit: 100 }); - const otherRepositoryList = yield* decodeThreadListResult( - otherRepositoryListCall.structuredContent, - ).pipe(Effect.orDie); - expect( - otherRepositoryList.threads.find( - (thread) => thread.threadId === emptyThread.threadId, - ), - ).toMatchObject({ linkedPullRequest: null, pullRequests: [otherRepositoryLink] }); - expect( - otherRepositoryList.threads.find((thread) => thread.threadId === parentThreadId), - ).toMatchObject({ pullRequests: [] }); - - const regenerateCall = yield* invoke("t3_thread_update", { - threadId: emptyThread.threadId, - action: "regenerate_title", - clientRequestId: "metadata-regenerate-title-1", - }); - const regenerating = yield* decodeThreadUpdateResult( - regenerateCall.structuredContent, - ).pipe(Effect.orDie); - expect(regenerating).toMatchObject({ - threadId: emptyThread.threadId, - action: "regenerate_title", - titleRegeneration: { requestId: regenerating.commandId }, - }); - const promptedProjection = yield* waitForProjection( - orchestrator, - promptedThread.threadId, - (projection) => projection.runs.some((run) => run.status === "completed"), - ); - expect(promptedProjection.thread.lineage.parentThreadId).toBeNull(); - expect( - promptedProjection.messages - .filter((message) => message.role === "user") - .map((message) => message.text), - ).toEqual([createdThreadPrompt]); - const createdThreadItems = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).visibleTurnItems - .map((row) => row.item) - .filter((item) => item.type === "thread_created"); - expect( - createdThreadItems.map((item) => ({ - targetThreadId: item.targetThreadId, - targetRunId: item.targetRunId, - title: item.title, - providerInstanceId: item.targetProviderInstanceId, - model: item.targetModel, - })), - ).toEqual([ - { - targetThreadId: emptyThread.threadId, - targetRunId: null, - title: emptyThread.title, - providerInstanceId: codexInstanceId, - model: codexModel, - }, - { - targetThreadId: promptedThread.threadId, - targetRunId: promptedThread.runId, - title: promptedThread.title, - providerInstanceId: claudeInstanceId, - model: claudeModel, - }, - ]); - - const repeatedCreateCall = yield* invoke("create_threads", createInput); - const repeatedCreated = yield* decodeCreateThreadsResult( - repeatedCreateCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedCreated.threads.map((thread) => thread.threadId)).toEqual( - created.threads.map((thread) => thread.threadId), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).visibleTurnItems.filter( - (row) => { - if (row.item.type !== "thread_created") return false; - const targetThreadId = row.item.targetThreadId; - return created.threads.some((thread) => thread.threadId === targetThreadId); - }, - ), - ).toHaveLength(2); - - const promptedReadCall = yield* invoke("t3_thread_read", { - threadId: promptedThread.threadId, - limit: 1, - }); - const promptedRead = yield* decodeThreadReadResult( - promptedReadCall.structuredContent, - ).pipe(Effect.orDie); - expect(promptedRead.thread.status).toBe("completed"); - expect(promptedRead.thread).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(promptedRead.items.map((item) => item.type)).toEqual(["user_message"]); - expect(promptedRead.items[0]).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(promptedRead.hasMore).toBe(true); - const promptedReadNextCall = yield* invoke("t3_thread_read", { - threadId: promptedThread.threadId, - afterPosition: promptedRead.nextPosition, - limit: 1, - }); - const promptedReadNext = yield* decodeThreadReadResult( - promptedReadNextCall.structuredContent, - ).pipe(Effect.orDie); - expect(promptedReadNext.items.map((item) => item.type)).toEqual(["assistant_message"]); - expect(promptedReadNext.items[0]?.text).toBe( - `Claude completed: ${createdThreadPrompt}`, - ); - - const forkedThreadId = ThreadId.make("thread:mcp-orchestrator-inherited-read"); - yield* orchestrator.dispatch({ - type: "thread.fork", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-orchestrator-inherited-read"), - sourceThreadId: promptedThread.threadId, - targetThreadId: forkedThreadId, - sourcePoint: { type: "latest_stable" }, - title: "Inherited read thread", - }); - const forkedProjection = yield* orchestrator.getThreadProjection(forkedThreadId); - expect(forkedProjection.messages).toEqual([]); - expect( - forkedProjection.visibleTurnItems.some( - (row) => - row.sourceThreadId === promptedThread.threadId && - row.item.type === "user_message", - ), - ).toBe(true); - - const forkedReadCall = yield* invoke("t3_thread_read", { - threadId: forkedThreadId, - }); - const forkedRead = yield* decodeThreadReadResult(forkedReadCall.structuredContent).pipe( - Effect.orDie, - ); - expect( - forkedRead.items.find((item) => item.text === createdThreadPrompt), - ).toMatchObject({ - sourceThreadId: promptedThread.threadId, - createdBy: "agent", - creationSource: "mcp", - }); - - const ordinaryLoopPrompt = "Run an ordinary thread loop iteration."; - const sendCall = yield* invoke("t3_thread_send", { - threadId: emptyThread.threadId, - message: ordinaryLoopPrompt, - clientRequestId: "ordinary-loop-send-1", - }); - const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe( - Effect.orDie, - ); - const sentSource = yield* orchestrator.getThreadProjection(emptyThread.threadId); - expect( - sentSource.messages.find((message) => message.id === sent.messageId), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - sentSource.turnItems.find( - (item) => item.type === "user_message" && item.messageId === sent.messageId, - ), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect(sent.delivery).toBe("started"); - const waitCall = yield* invoke("t3_thread_wait", { - threadId: emptyThread.threadId, - runId: sent.runId, - timeoutMs: 10_000, - }); - const waited = yield* decodeThreadWaitResult(waitCall.structuredContent).pipe( - Effect.orDie, - ); - expect(waited).toMatchObject({ - runId: sent.runId, - status: "completed", - timedOut: false, - }); - const repeatedSendCall = yield* invoke("t3_thread_send", { - threadId: emptyThread.threadId, - message: ordinaryLoopPrompt, - clientRequestId: "ordinary-loop-send-1", - }); - const repeatedSend = yield* decodeThreadSendResult( - repeatedSendCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedSend.runId).toBe(sent.runId); - expect( - (yield* orchestrator.getThreadProjection(emptyThread.threadId)).runs, - ).toHaveLength(1); - - const activeThreadCall = yield* invoke("create_threads", { - threads: [{ prompt: cancellationPrompt, title: "Managed active thread" }], - clientRequestId: "managed-active-thread-1", - }); - const activeThread = (yield* decodeCreateThreadsResult( - activeThreadCall.structuredContent, - ).pipe(Effect.orDie)).threads[0]!; - const activeThreadItem = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).visibleTurnItems - .map((row) => row.item) - .find( - (item) => - item.type === "thread_created" && item.targetThreadId === activeThread.threadId, - ); - expect(activeThreadItem).toMatchObject({ - type: "thread_created", - title: "Managed active thread", - targetThreadId: activeThread.threadId, - targetRunId: activeThread.runId, - targetProviderInstanceId: codexInstanceId, - targetModel: codexModel, - }); - const activeProjection = yield* waitForProjection( - orchestrator, - activeThread.threadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const activeRun = activeProjection.runs[0]!; - const activeTimeoutCall = yield* invoke("t3_thread_wait", { - threadId: activeThread.threadId, - runId: activeRun.id, - timeoutMs: 1, - }); - const activeTimeout = yield* decodeThreadWaitResult( - activeTimeoutCall.structuredContent, - ).pipe(Effect.orDie); - expect(activeTimeout).toMatchObject({ - runId: activeRun.id, - status: "running", - timedOut: true, - }); - const steerCall = yield* invoke("t3_thread_send", { - threadId: activeThread.threadId, - message: "Include the latest parent guidance before finishing.", - mode: "steer", - clientRequestId: "managed-active-steer-1", - }); - const steered = yield* decodeThreadSendResult(steerCall.structuredContent).pipe( - Effect.orDie, - ); - expect(steered).toMatchObject({ - runId: activeRun.id, - delivery: "steered", - }); - const steeredSource = yield* orchestrator.getThreadProjection(activeThread.threadId); - expect( - steeredSource.messages.find((message) => message.id === steered.messageId), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - expect( - steeredSource.turnItems.find( - (item) => item.type === "user_message" && item.messageId === steered.messageId, - ), - ).toMatchObject({ - senderThreadId: parentThreadId, - }); - const interruptCall = yield* invoke("t3_thread_interrupt", { - threadId: activeThread.threadId, - reason: "The orchestration loop has enough evidence.", - clientRequestId: "managed-active-interrupt-1", - }); - const interrupted = yield* decodeThreadInterruptResult( - interruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(interrupted).toMatchObject({ - runId: activeRun.id, - status: "interrupt_requested", - }); - const interruptedWaitCall = yield* invoke("t3_thread_wait", { - threadId: activeThread.threadId, - runId: activeRun.id, - timeoutMs: 10_000, - }); - const interruptedWait = yield* decodeThreadWaitResult( - interruptedWaitCall.structuredContent, - ).pipe(Effect.orDie); - expect(interruptedWait.status).toBe("interrupted"); - const repeatedInterruptCall = yield* invoke("t3_thread_interrupt", { - threadId: activeThread.threadId, - runId: activeRun.id, - }); - const repeatedInterrupt = yield* decodeThreadInterruptResult( - repeatedInterruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(repeatedInterrupt.status).toBe("interrupted"); - - const foreignThreadId = ThreadId.make("thread:mcp-foreign-project"); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-foreign-project:create"), - threadId: foreignThreadId, - projectId: ProjectId.make("project:mcp-foreign"), - title: "Foreign project thread", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - const foreignOrganizeCall = yield* invoke("t3_thread_organize", { - threadId: foreignThreadId, - action: "pin", - }); - expect(foreignOrganizeCall.isError).toBe(false); - expect((yield* orchestrator.getThreadShell(foreignThreadId))?.pinnedAt).not.toBeNull(); - - const foreignReadCall = yield* invoke("t3_thread_read", { - threadId: foreignThreadId, - }); - expect(foreignReadCall.structuredContent).toMatchObject({ - thread: { threadId: foreignThreadId, projectId: "project:mcp-foreign" }, - }); - const foreignUpdateCall = yield* invoke("t3_thread_update", { - threadId: foreignThreadId, - action: "rename", - title: "Renamed from another project", - }); - expect(foreignUpdateCall.structuredContent).toMatchObject({ - threadId: foreignThreadId, - title: "Renamed from another project", - }); - const foreignListCall = yield* invoke("t3_thread_list", { - projectId: "project:mcp-foreign", - }); - const foreignListed = yield* decodeThreadListResult( - foreignListCall.structuredContent, - ).pipe(Effect.orDie); - expect(foreignListed.threads.map((thread) => thread.threadId)).toEqual([ - foreignThreadId, - ]); - const listCall = yield* invoke("t3_thread_list", { - includeSubagents: false, - limit: 100, - }); - const listed = yield* decodeThreadListResult(listCall.structuredContent).pipe( - Effect.orDie, - ); - expect(listed.projectId).toBe(projectId); - expect(listed.threads.map((thread) => thread.threadId)).toEqual( - expect.arrayContaining([ - parentThreadId, - emptyThread.threadId, - promptedThread.threadId, - activeThread.threadId, - ]), - ); - expect( - listed.threads.find((thread) => thread.threadId === emptyThread.threadId), - ).toMatchObject({ - createdBy: "agent", - creationSource: "mcp", - }); - expect(listed.threads.some((thread) => thread.threadId === foreignThreadId)).toBe( - false, - ); - expect( - listed.threads.some((thread) => thread.relationshipToParent === "subagent"), - ).toBe(false); - - const upgradedCall = yield* invoke("delegate_task", { - task: cancellationPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - }, - mode: "wait", - timeoutMs: 1, - clientRequestId: "delegate-wait-upgrade-1", - }); - const upgradedDelegated = yield* decodeDelegateTaskResult( - upgradedCall.structuredContent, - ).pipe(Effect.orDie); - expect(upgradedDelegated.status).toBe("running"); - yield* waitForProjection(orchestrator, upgradedDelegated.childThreadId, (projection) => - projection.providerTurns.some((turn) => turn.status === "running"), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === upgradedDelegated.taskId, - )?.completionWake, - ).toBe("always"); - const upgradeCancelCall = yield* invoke("task_cancel", { - taskId: upgradedDelegated.taskId, - reason: "Terminalize while the parent run is live.", - clientRequestId: "cancel-wait-upgrade-1", - }); - expect(upgradeCancelCall.isError).toBe(false); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.subagents.some( - (task) => task.id === upgradedDelegated.taskId && task.status === "interrupted", - ), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === upgradedDelegated.taskId, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - yield* expectOffersToStay(0); - - const questionWait = yield* invoke("delegate_task", { - task: cancellationPrompt, - title: "Wait question", - target: { providerInstanceId: codexInstanceId, model: codexModel }, - mode: "wait", - timeoutMs: 30_000, - clientRequestId: "delegate-wait-question-1", - }).pipe(Effect.forkChild); - const question = (yield* waitForProjection(orchestrator, parentThreadId, (p) => - p.subagents.some( - (task) => task.title === "Wait question" && task.childThreadId !== null, - ), - )).subagents.find((task) => task.title === "Wait question")!; - const asking = yield* waitForProjection(orchestrator, question.childThreadId!, (p) => - p.providerTurns.some((turn) => turn.status === "running"), - ); - const askedAt = yield* DateTime.now; - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("event:wait-question:live"), - type: "runtime-request.updated", - threadId: question.childThreadId!, - occurredAt: askedAt, - payload: { - id: RuntimeRequestId.make("request:wait-question"), - nodeId: question.id, - providerTurnId: null, - nativeRequestRef: null, - kind: "user_input", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: asking.providerSessions[0]!.id, - }, - createdAt: askedAt, - resolvedAt: null, - }, - }, - ], - }); - expect((yield* Fiber.join(questionWait)).structuredContent).toMatchObject({ - status: "running", - hasPendingQuestion: true, - waitTimedOut: false, - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === question.id, - )?.completionWake, - ).toBe("always"); - yield* invoke("task_cancel", { - taskId: question.id, - clientRequestId: "cancel-wait-question-1", - }); - yield* waitForProjection(orchestrator, parentThreadId, (p) => - p.subagents.some((task) => task.id === question.id && task.status === "interrupted"), - ); - - // Stop disposes delivery even if a live approval record survives it. - yield* (yield* EventSink.EventSinkV2).write({ - events: [ - { - id: EventId.make("event:approval-after-stop"), - type: "runtime-request.updated", - threadId: question.childThreadId!, - occurredAt: askedAt, - payload: { - id: RuntimeRequestId.make("request:approval-after-stop"), - nodeId: asking.nodes.find((node) => node.runId === asking.runs[0]!.id)!.id, - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: asking.providerSessions[0]!.id, - }, - createdAt: askedAt, - resolvedAt: null, - }, - }, - ], - }); - expect( - (yield* invoke("task_status", { taskId: question.id })).structuredContent, - ).toMatchObject({ - status: "interrupted", - pendingApproval: null, - }); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === question.id, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - - const terminalUpgrade = yield* orchestrator.dispatch({ - type: "delegated_task.wake-policy", - commandId: CommandId.make("command:mcp-parent:wake-policy-terminal"), - parentThreadId, - taskId: delegated.taskId, - completionWake: "always", - }); - expect( - terminalUpgrade.storedEvents.some( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.id === delegated.taskId && - stored.event.payload.completionWake === "always", - ), - ).toBe(true); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === delegated.taskId, - )?.completionWake, - ).toBe("always"); - yield* expectOffersToStay(0); - - const legacyDispatch = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-parent:delegate-legacy"), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: cancellationPrompt, - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - }); - const legacyTaskEvent = legacyDispatch.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (legacyTaskEvent?.event.type !== "subagent.updated") { - return yield* Effect.die(new Error("Legacy delegated task projection missing.")); - } - const legacyTask = legacyTaskEvent.event.payload; - expect(legacyTask.completionWake).toBeUndefined(); - if (legacyTask.childThreadId === null) { - return yield* Effect.die(new Error("Legacy delegated task child thread missing.")); - } - const legacyChildThreadId = legacyTask.childThreadId; - yield* waitForProjection(orchestrator, legacyChildThreadId, (projection) => - projection.providerTurns.some((turn) => turn.status === "running"), - ); - yield* expectOffersToStay(0); - - const stopQueuedDispatch = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-parent:stop-queued-delivery"), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRootNodeId, - task: "Complete the stop barrier delivery task.", - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const stopQueuedTaskEvent = stopQueuedDispatch.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (stopQueuedTaskEvent?.event.type !== "subagent.updated") { - return yield* Effect.die( - new Error("Stop barrier delegated task projection missing."), - ); - } - const stopQueuedTaskId = stopQueuedTaskEvent.event.payload.id; - const stopQueuedProjection = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.subagents.find((task) => task.id === stopQueuedTaskId)?.status === - "completed" && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery !== null && - projection.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion - ?.delivery !== undefined, - ); - const stopQueuedDelivery = stopQueuedProjection.runs.find( - (run) => run.id === parentRun.id, - )?.delegatedCompletion?.delivery; - if (stopQueuedDelivery === undefined || stopQueuedDelivery === null) { - return yield* Effect.die(new Error("Stop barrier delivery reservation missing.")); - } - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-parent:dispatch-stop-queued-delivery"), - threadId: parentThreadId, - messageId: stopQueuedDelivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: parentRun.id, - generation: stopQueuedDelivery.generation, - taskIds: stopQueuedDelivery.taskIds, - }, - }); - const queuedStopDelivery = yield* waitForProjection( - orchestrator, - parentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === stopQueuedDelivery.messageId && run.status === "queued", - ), - ); - const queuedStopDeliveryRun = queuedStopDelivery.runs.find( - (run) => run.userMessageId === stopQueuedDelivery.messageId, - ); - if (queuedStopDeliveryRun === undefined) { - return yield* Effect.die(new Error("Queued stop barrier delivery missing.")); - } - yield* Ref.set(continuationOffers, []); - - const parentStop = yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:mcp-parent:interrupt-wake"), - threadId: parentThreadId, - runId: parentRun.id, - reason: "Settle the parent before the legacy child terminalizes.", - }); - yield* waitForProjection(orchestrator, parentThreadId, (projection) => - projection.runs.every( - (run) => - run.status !== "preparing" && - run.status !== "starting" && - run.status !== "running", - ), - ); - const stoppedParent = yield* orchestrator.getThreadProjection(parentThreadId); - expect(stoppedParent.runs.some((run) => run.id === parentRun.id)).toBe(true); - expect( - parentStop.storedEvents.some( - (stored) => - stored.event.type === "run.updated" && - stored.event.payload.id === parentRun.id && - stored.event.payload.delegatedCompletion?.disposition === "stopped", - ), - ).toBe(true); - expect( - stoppedParent.runs.find((run) => run.id === parentRun.id)?.delegatedCompletion, - ).toMatchObject({ disposition: "stopped", delivery: null }); - expect( - stoppedParent.runs.find((run) => run.id === queuedStopDeliveryRun.id)?.status, - ).toBe("cancelled"); - expect( - stoppedParent.subagents.find((task) => task.id === legacyTask.id)?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - const legacyChildRun = (yield* orchestrator.getThreadProjection( - legacyChildThreadId, - )).runs.find((run) => run.status === "running"); - if (legacyChildRun === undefined) { - return yield* Effect.die( - new Error("Still-running child was missing after parent Stop."), - ); - } - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:mcp-parent:interrupt-legacy-after-parent-stop"), - threadId: legacyChildThreadId, - runId: legacyChildRun.id, - reason: "Terminalize after the parent Stop barrier.", - }); - yield* waitForProjection(orchestrator, legacyChildThreadId, (projection) => - projection.runs.some((run) => run.status === "interrupted"), - ); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === legacyTask.id, - )?.completionDelivery, - ).toMatchObject({ state: "disposed" }); - yield* expectOffersToStay(0); - - const settledUpgrade = yield* orchestrator.dispatch({ - type: "delegated_task.wake-policy", - commandId: CommandId.make("command:mcp-parent:wake-policy-settled"), - parentThreadId, - taskId: legacyTask.id, - completionWake: "always", - }); - expect( - settledUpgrade.storedEvents.some( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.id === legacyTask.id && - stored.event.payload.completionWake === "always", - ), - ).toBe(true); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( - (task) => task.id === legacyTask.id, - )?.completionWake, - ).toBe("always"); - yield* expectOffersToStay(0); - - const lateParentThreadId = ThreadId.make("thread:mcp-late-completion-parent"); - const lateParentGate = yield* Deferred.make(); - const lateDeliveryGate = yield* Deferred.make(); - parentTerminalGates.set(lateParentThreadId, lateParentGate); - deliveryTerminalGates.set(lateParentThreadId, lateDeliveryGate); - yield* Ref.set(continuationOffers, []); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-late-parent:create"), - threadId: lateParentThreadId, - projectId, - title: "Late completion parent", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-late-parent:start"), - threadId: lateParentThreadId, - messageId: MessageId.make("message:mcp-late-parent:start"), - text: "Hold this parent until its completion delivery is queued.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - const lateParentProjection = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const lateParentRun = lateParentProjection.runs.find((run) => run.status === "running"); - if (lateParentRun?.rootNodeId === null || lateParentRun === undefined) { - return yield* Effect.die(new Error("Late completion parent run missing.")); - } - const earlyLateDelivery = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-late-parent:early-task"), - parentThreadId: lateParentThreadId, - parentRunId: lateParentRun.id, - parentNodeId: lateParentRun.rootNodeId, - task: "Complete before the first delivery starts.", - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const lateChild = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-late-parent:late-task"), - parentThreadId: lateParentThreadId, - parentRunId: lateParentRun.id, - parentNodeId: lateParentRun.rootNodeId, - task: cancellationPrompt, - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const taskFromDispatch = (result: typeof earlyLateDelivery) => { - const taskEvent = result.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (taskEvent?.event.type !== "subagent.updated") { - throw new Error("Late completion delegated task projection missing."); - } - return taskEvent.event.payload; - }; - const earlyLateTask = taskFromDispatch(earlyLateDelivery); - const lateTask = taskFromDispatch(lateChild); - const thirdLateChild = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-late-parent:third-late-task"), - parentThreadId: lateParentThreadId, - parentRunId: lateParentRun.id, - parentNodeId: lateParentRun.rootNodeId, - task: cancellationPrompt, - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const thirdLateTask = taskFromDispatch(thirdLateChild); - const fourthLateTask = taskFromDispatch( - yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-late-parent:fourth-late-task"), - parentThreadId: lateParentThreadId, - parentRunId: lateParentRun.id, - parentNodeId: lateParentRun.rootNodeId, - task: cancellationPrompt, - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }), - ); - if ( - lateTask.childThreadId === null || - thirdLateTask.childThreadId === null || - fourthLateTask.childThreadId === null - ) { - return yield* Effect.die(new Error("Late completion child thread missing.")); - } - const beforeFirstDelivery = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.subagents.find((task) => task.id === earlyLateTask.id)?.status === - "completed" && - projection.subagents.find((task) => task.id === lateTask.id)?.status === - "running" && - projection.runs.find((run) => run.id === lateParentRun.id)?.delegatedCompletion - ?.delivery !== null && - projection.runs.find((run) => run.id === lateParentRun.id)?.delegatedCompletion - ?.delivery !== undefined, - ); - const firstLateDelivery = beforeFirstDelivery.runs.find( - (run) => run.id === lateParentRun.id, - )?.delegatedCompletion?.delivery; - if (firstLateDelivery === undefined || firstLateDelivery === null) { - return yield* Effect.die(new Error("First late completion delivery missing.")); - } - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-late-parent:dispatch-first-delivery"), - threadId: lateParentThreadId, - messageId: firstLateDelivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: lateParentRun.id, - generation: firstLateDelivery.generation, - taskIds: firstLateDelivery.taskIds, - }, - }); - yield* Deferred.succeed(lateParentGate, undefined); - const activeFirstDelivery = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === firstLateDelivery.messageId && run.status === "running", - ), - ); - const activeFirstDeliveryRun = activeFirstDelivery.runs.find( - (run) => run.userMessageId === firstLateDelivery.messageId, - ); - if (activeFirstDeliveryRun === undefined) { - return yield* Effect.die(new Error("First late completion delivery did not start.")); - } - const lateChildProjection = yield* orchestrator.getThreadProjection( - lateTask.childThreadId, - ); - const lateChildRun = lateChildProjection.runs[0]; - if (lateChildRun === undefined) { - return yield* Effect.die(new Error("Late completion child run missing.")); - } - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("command:mcp-late-parent:interrupt-late-child"), - threadId: lateTask.childThreadId, - runId: lateChildRun.id, - reason: "Terminalize after the first completion delivery started.", - }); - yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.subagents.find((task) => task.id === lateTask.id)?.completionDelivery - ?.state === "pending", - ); - yield* Deferred.succeed(lateDeliveryGate, undefined); - const successorReserved = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => { - const delivery = projection.runs.find((run) => run.id === lateParentRun.id) - ?.delegatedCompletion?.delivery; - return ( - delivery !== undefined && - delivery !== null && - delivery.generation === firstLateDelivery.generation + 1 && - delivery.taskIds.length === 1 && - delivery.taskIds[0] === lateTask.id - ); - }, - ); - const successorDelivery = successorReserved.runs.find( - (run) => run.id === lateParentRun.id, - )?.delegatedCompletion?.delivery; - expect(successorDelivery).toMatchObject({ - generation: firstLateDelivery.generation + 1, - taskIds: [lateTask.id], - }); - const lateOffers = yield* waitForContinuationOffers(2); - expect(lateOffers).toHaveLength(2); - expect( - successorReserved.runs.filter((run) => { - const message = successorReserved.messages.find( - (candidate) => candidate.id === run.userMessageId, - ); - return message?.delegatedCompletion?.parentRunId === lateParentRun.id; - }), - ).toHaveLength(1); - if (successorDelivery === undefined || successorDelivery === null) { - return yield* Effect.die(new Error("Late completion successor delivery missing.")); - } - - const successorGate = yield* Deferred.make(); - deliveryTerminalGates.set(lateParentThreadId, successorGate); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-late-parent:dispatch-successor-delivery"), - threadId: lateParentThreadId, - messageId: successorDelivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: lateParentRun.id, - generation: successorDelivery.generation, - taskIds: successorDelivery.taskIds, - }, - }); - const activeSuccessor = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === successorDelivery.messageId && run.status === "running", - ), - ); - const activeSuccessorRun = activeSuccessor.runs.find( - (run) => run.userMessageId === successorDelivery.messageId, - ); - if (activeSuccessorRun === undefined) { - return yield* Effect.die(new Error("Late completion successor did not start.")); - } - expect(activeSuccessor.turnItems).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "notification", - runId: activeSuccessorRun.id, - source: { - kind: "delegated_task", - taskIds: successorDelivery.taskIds, - childThreadId: lateTask.childThreadId, - }, - outcome: "cancelled", - summary: `Delegated task "${cancellationPrompt}" stopped`, - }), - ]), - ); - expect( - activeSuccessor.turnItems.some( - (item) => - item.type === "user_message" && item.messageId === successorDelivery.messageId, - ), - ).toBe(false); - for (const [label, childThreadId] of [ - ["third", thirdLateTask.childThreadId], - ["fourth", fourthLateTask.childThreadId], - ] as const) { - const childProjection = yield* waitForProjection( - orchestrator, - childThreadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const childRun = childProjection.runs.find((run) => run.status === "running"); - if (childRun === undefined) { - return yield* Effect.die(new Error(`${label} late completion child run missing.`)); - } - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make(`command:mcp-late-parent:interrupt-${label}-late-child`), - threadId: childThreadId, - runId: childRun.id, - reason: "Terminalize while the successor delivery is running.", - }); - } - const pendingDuringSuccessor = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - [thirdLateTask.id, fourthLateTask.id].every( - (taskId) => - projection.subagents.find((task) => task.id === taskId)?.completionDelivery - ?.state === "pending", - ), - ); - expect( - pendingDuringSuccessor.runs.find((run) => run.id === lateParentRun.id) - ?.delegatedCompletion?.delivery, - ).toMatchObject({ - generation: successorDelivery.generation, - taskIds: successorDelivery.taskIds, - }); - yield* expectOffersToStay(2); - yield* Deferred.succeed(successorGate, undefined); - const batchedReserved = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => { - const delivery = projection.runs.find((run) => run.id === lateParentRun.id) - ?.delegatedCompletion?.delivery; - return ( - delivery !== undefined && - delivery !== null && - delivery.generation === successorDelivery.generation + 1 && - projection.runs.find((run) => run.id === activeSuccessorRun.id)?.status === - "completed" - ); - }, - ); - const batchedDelivery = batchedReserved.runs.find((run) => run.id === lateParentRun.id) - ?.delegatedCompletion?.delivery; - if (batchedDelivery === undefined || batchedDelivery === null) { - return yield* Effect.die(new Error("Batched late completion delivery missing.")); - } - expect([...batchedDelivery.taskIds].toSorted()).toEqual( - [thirdLateTask.id, fourthLateTask.id].toSorted(), - ); - expect( - [lateTask.id, thirdLateTask.id, fourthLateTask.id].map( - (taskId) => - batchedReserved.subagents.find((task) => task.id === taskId)?.completionDelivery - ?.state, - ), - ).toEqual(["delivered", "claimed", "claimed"]); - yield* waitForContinuationOffers(3); - yield* expectOffersToStay(3); - expect( - batchedReserved.runs.filter((run) => { - const message = batchedReserved.messages.find( - (candidate) => candidate.id === run.userMessageId, - ); - return ( - message?.delegatedCompletion?.parentRunId === lateParentRun.id && - run.status !== "completed" - ); - }), - ).toHaveLength(0); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-late-parent:dispatch-batched-delivery"), - threadId: lateParentThreadId, - messageId: batchedDelivery.messageId, - text: "Delegated tasks reached terminal states.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: lateParentRun.id, - generation: batchedDelivery.generation, - taskIds: batchedDelivery.taskIds, - }, - }); - const drainedCohort = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.find((run) => run.id === lateParentRun.id)?.delegatedCompletion - ?.delivery === null && - projection.runs.some( - (run) => - run.userMessageId === batchedDelivery.messageId && run.status === "completed", - ), - ); - expect( - [earlyLateTask.id, lateTask.id, thirdLateTask.id, fourthLateTask.id].map( - (taskId) => - drainedCohort.subagents.find((task) => task.id === taskId)?.completionDelivery - ?.state, - ), - ).toEqual(["delivered", "delivered", "delivered", "delivered"]); - yield* expectOffersToStay(3); - - const removeParentGate = yield* Deferred.make(); - parentTerminalGates.set(lateParentThreadId, removeParentGate); - const removeParentMessageId = MessageId.make("message:mcp-late-parent:remove-start"); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-late-parent:remove-start"), - threadId: lateParentThreadId, - messageId: removeParentMessageId, - text: "Keep the parent active while removing automatic delivery.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - const removeParentProjection = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.some( - (run) => run.userMessageId === removeParentMessageId && run.status === "running", - ), - ); - const removeParentRun = removeParentProjection.runs.find( - (run) => run.userMessageId === removeParentMessageId, - ); - if (removeParentRun?.rootNodeId === null || removeParentRun === undefined) { - return yield* Effect.die(new Error("Queue Remove parent run missing.")); - } - const removeDelegation = yield* orchestrator.dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make("command:mcp-late-parent:remove-delegate"), - parentThreadId: lateParentThreadId, - parentRunId: removeParentRun.id, - parentNodeId: removeParentRun.rootNodeId, - task: "Complete before Queue Remove disposes this automatic delivery.", - modelSelection: claudeSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }); - const removeTaskEvent = removeDelegation.storedEvents.find( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.origin === "app_owned", - ); - if (removeTaskEvent?.event.type !== "subagent.updated") { - return yield* Effect.die( - new Error("Queue Remove delegated task projection missing."), - ); - } - const removeTask = removeTaskEvent.event.payload; - const removeReserved = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.subagents.find((task) => task.id === removeTask.id)?.status === - "completed" && - projection.runs.find((run) => run.id === removeParentRun.id)?.delegatedCompletion - ?.delivery !== null && - projection.runs.find((run) => run.id === removeParentRun.id)?.delegatedCompletion - ?.delivery !== undefined, - ); - const removeDelivery = removeReserved.runs.find((run) => run.id === removeParentRun.id) - ?.delegatedCompletion?.delivery; - if (removeDelivery === undefined || removeDelivery === null) { - return yield* Effect.die(new Error("Queue Remove delivery reservation missing.")); - } - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make("command:mcp-late-parent:remove-dispatch"), - threadId: lateParentThreadId, - messageId: removeDelivery.messageId, - text: "Delegated task reached a terminal state.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: removeParentRun.id, - generation: removeDelivery.generation, - taskIds: removeDelivery.taskIds, - }, - }); - const removeQueued = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.some( - (run) => - run.userMessageId === removeDelivery.messageId && run.status === "queued", - ), - ); - const removeQueuedRun = removeQueued.runs.find( - (run) => run.userMessageId === removeDelivery.messageId, - ); - if (removeQueuedRun === undefined) { - return yield* Effect.die(new Error("Queue Remove delivery did not queue.")); - } - yield* Ref.set(continuationOffers, []); - yield* orchestrator.dispatch({ - type: "queued-run.cancel", - commandId: CommandId.make("command:mcp-late-parent:remove-queued-delivery"), - threadId: lateParentThreadId, - runId: removeQueuedRun.id, - }); - const removedDelivery = yield* waitForProjection( - orchestrator, - lateParentThreadId, - (projection) => - projection.runs.find((run) => run.id === removeParentRun.id)?.delegatedCompletion - ?.disposition === "disposed" && - projection.runs.find((run) => run.id === removeQueuedRun.id)?.status === - "cancelled" && - projection.subagents.find((task) => task.id === removeTask.id)?.completionDelivery - ?.state === "disposed", - ); - expect( - removedDelivery.runs.find((run) => run.id === removeParentRun.id) - ?.delegatedCompletion, - ).toMatchObject({ disposition: "disposed", delivery: null }); - expect( - removedDelivery.subagents.find((task) => task.id === removeTask.id), - ).toMatchObject({ result: expect.any(String), status: "completed" }); - yield* expectOffersToStay(0); - - const fanoutParentThreadId = ThreadId.make("thread:mcp-fanout-parent"); - const fanoutParentGate = yield* Deferred.make(); - const firstFanoutDeliveryGate = yield* Deferred.make(); - const secondFanoutDeliveryGate = yield* Deferred.make(); - parentTerminalGates.set(fanoutParentThreadId, fanoutParentGate); - deliveryTerminalGates.set(fanoutParentThreadId, firstFanoutDeliveryGate); - yield* Ref.set(continuationOffers, []); - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-fanout-parent:create"), - threadId: fanoutParentThreadId, - projectId, - title: "Fan-out parent", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-fanout-parent:start"), - threadId: fanoutParentThreadId, - messageId: MessageId.make("message:mcp-fanout-parent:start"), - text: "Fan out twelve review agents.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - const fanoutStarted = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const fanoutRun = fanoutStarted.runs.find((run) => run.status === "running"); - if (fanoutRun === undefined || fanoutRun.rootNodeId === null) { - return yield* Effect.die(new Error("Fan-out parent run missing.")); - } - const fanoutRootNodeId = fanoutRun.rootNodeId; - const fanoutTasks = yield* Effect.forEach( - Array.from({ length: 12 }, (_, index) => index), - (index) => - orchestrator - .dispatch({ - type: "delegated_task.request", - createdBy: "agent", - creationSource: "mcp", - commandId: CommandId.make(`command:mcp-fanout-parent:task-${index}`), - parentThreadId: fanoutParentThreadId, - parentRunId: fanoutRun.id, - parentNodeId: fanoutRootNodeId, - task: cancellationPrompt, - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - }) - .pipe(Effect.map(taskFromDispatch)), - ); - type FanoutTask = (typeof fanoutTasks)[number]; - const finishFanoutChildren = (tasks: ReadonlyArray) => - Effect.forEach( - tasks, - (task) => - Effect.gen(function* () { - const childThreadId = task.childThreadId; - if (childThreadId === null) { - return yield* Effect.die(new Error("Fan-out child thread missing.")); - } - const child = yield* waitForProjection( - orchestrator, - childThreadId, - (projection) => - projection.runs.some((run) => run.status === "running") && - projection.providerTurns.some((turn) => turn.status === "running"), - ); - const childRun = child.runs.find((run) => run.status === "running"); - if (childRun === undefined) { - return yield* Effect.die(new Error("Fan-out child run missing.")); - } - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make(`command:mcp-fanout-parent:finish-${task.id}`), - threadId: childThreadId, - runId: childRun.id, - reason: "Finish this fan-out child.", - }); - }), - { discard: true }, - ); - const fanoutDelivery = (projection: OrchestrationV2ThreadProjection) => - projection.runs.find((run) => run.id === fanoutRun.id)?.delegatedCompletion - ?.delivery ?? null; - const fanoutDeliveryRuns = (projection: OrchestrationV2ThreadProjection) => - projection.runs.filter( - (run) => - projection.messages.find((message) => message.id === run.userMessageId) - ?.delegatedCompletion?.parentRunId === fanoutRun.id, - ); - const expectAtMostOneOutstandingDelivery = ( - projection: OrchestrationV2ThreadProjection, - ) => - expect( - fanoutDeliveryRuns(projection).filter( - (run) => - run.status !== "completed" && - run.status !== "failed" && - run.status !== "cancelled" && - run.status !== "interrupted", - ).length, - ).toBeLessThanOrEqual(1); - const deliveryStates = ( - projection: OrchestrationV2ThreadProjection, - tasks: ReadonlyArray, - ) => - tasks.map( - (task) => - projection.subagents.find((candidate) => candidate.id === task.id) - ?.completionDelivery?.state, - ); - const sortedIds = (ids: ReadonlyArray) => [...ids].toSorted(); - const idsOf = (tasks: ReadonlyArray) => - sortedIds(tasks.map((task) => task.id)); - const dispatchFanoutDelivery = ( - label: string, - delivery: OrchestrationV2DelegatedCompletionDelivery, - ) => - orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "agent", - creationSource: "server", - commandId: CommandId.make(`command:mcp-fanout-parent:dispatch-${label}`), - threadId: fanoutParentThreadId, - messageId: delivery.messageId, - text: "Delegated tasks reached terminal states.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "queue_after_active" }, - delegatedCompletion: { - parentRunId: fanoutRun.id, - generation: delivery.generation, - taskIds: delivery.taskIds, - }, - }); - const deliveryRunStatus = ( - projection: OrchestrationV2ThreadProjection, - delivery: OrchestrationV2DelegatedCompletionDelivery, - ) => projection.runs.find((run) => run.userMessageId === delivery.messageId)?.status; - - const beforeFirstStarts = fanoutTasks.slice(0, 2); - const duringFirst = fanoutTasks.slice(2, 7); - const beforeSecondStarts = fanoutTasks.slice(7, 10); - const duringSecond = fanoutTasks.slice(10, 12); - - yield* finishFanoutChildren(beforeFirstStarts); - const firstReserved = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryStates(projection, beforeFirstStarts).every( - (state) => state === "claimed", - ) && fanoutDelivery(projection)?.taskIds.length === 2, - ); - const firstFanoutDelivery = fanoutDelivery(firstReserved); - if (firstFanoutDelivery === null) { - return yield* Effect.die(new Error("First fan-out delivery missing.")); - } - expect(sortedIds(firstFanoutDelivery.taskIds)).toEqual(idsOf(beforeFirstStarts)); - yield* dispatchFanoutDelivery("first", firstFanoutDelivery); - yield* Deferred.succeed(fanoutParentGate, undefined); - yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => deliveryRunStatus(projection, firstFanoutDelivery) === "running", - ); - - yield* finishFanoutChildren(duringFirst); - const pendingDuringFirst = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryStates(projection, duringFirst).every((state) => state === "pending"), - ); - expect(fanoutDelivery(pendingDuringFirst)).toEqual(firstFanoutDelivery); - expectAtMostOneOutstandingDelivery(pendingDuringFirst); - - deliveryTerminalGates.set(fanoutParentThreadId, secondFanoutDeliveryGate); - yield* Deferred.succeed(firstFanoutDeliveryGate, undefined); - const secondReserved = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryRunStatus(projection, firstFanoutDelivery) === "completed" && - fanoutDelivery(projection)?.generation === firstFanoutDelivery.generation + 1, - ); - expect(sortedIds(fanoutDelivery(secondReserved)?.taskIds ?? [])).toEqual( - idsOf(duringFirst), - ); - expect(deliveryStates(secondReserved, beforeFirstStarts)).toEqual([ - "delivered", - "delivered", - ]); - - yield* finishFanoutChildren(beforeSecondStarts); - const secondJoined = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryStates(projection, beforeSecondStarts).every( - (state) => state === "claimed", - ) && fanoutDelivery(projection)?.taskIds.length === 8, - ); - const secondFanoutDelivery = fanoutDelivery(secondJoined); - if (secondFanoutDelivery === null) { - return yield* Effect.die(new Error("Second fan-out delivery missing.")); - } - expect(sortedIds(secondFanoutDelivery.taskIds)).toEqual( - idsOf([...duringFirst, ...beforeSecondStarts]), - ); - expectAtMostOneOutstandingDelivery(secondJoined); - yield* dispatchFanoutDelivery("second", secondFanoutDelivery); - yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => deliveryRunStatus(projection, secondFanoutDelivery) === "running", - ); - - yield* finishFanoutChildren(duringSecond); - const pendingDuringSecond = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryStates(projection, duringSecond).every((state) => state === "pending"), - ); - expectAtMostOneOutstandingDelivery(pendingDuringSecond); - - deliveryTerminalGates.delete(fanoutParentThreadId); - yield* Deferred.succeed(secondFanoutDeliveryGate, undefined); - const thirdReserved = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryRunStatus(projection, secondFanoutDelivery) === "completed" && - fanoutDelivery(projection)?.generation === secondFanoutDelivery.generation + 1, - ); - const thirdFanoutDelivery = fanoutDelivery(thirdReserved); - if (thirdFanoutDelivery === null) { - return yield* Effect.die(new Error("Third fan-out delivery missing.")); - } - expect(sortedIds(thirdFanoutDelivery.taskIds)).toEqual(idsOf(duringSecond)); - yield* dispatchFanoutDelivery("third", thirdFanoutDelivery); - const fanoutDrained = yield* waitForProjection( - orchestrator, - fanoutParentThreadId, - (projection) => - deliveryRunStatus(projection, thirdFanoutDelivery) === "completed" && - fanoutDelivery(projection) === null, - ); - expect(deliveryStates(fanoutDrained, fanoutTasks)).toEqual( - fanoutTasks.map(() => "delivered"), - ); - expect( - fanoutTasks.map( - (task) => - fanoutDrained.subagents.find((candidate) => candidate.id === task.id)?.status, - ), - ).toEqual(fanoutTasks.map(() => "interrupted")); - const drainedDeliveryRuns = fanoutDeliveryRuns(fanoutDrained); - expect(drainedDeliveryRuns.map((run) => run.status)).toEqual([ - "completed", - "completed", - "completed", - ]); - const deliveredTaskIds = drainedDeliveryRuns.flatMap( - (run) => - fanoutDrained.messages.find((message) => message.id === run.userMessageId) - ?.delegatedCompletion?.taskIds ?? [], - ); - expect(sortedIds(deliveredTaskIds)).toEqual(idsOf(fanoutTasks)); - const offeredDeliveries = new Set( - (yield* Ref.get(continuationOffers)).map( - (offer) => offer.delegatedCompletion?.messageId, - ), - ); - expect(offeredDeliveries).toEqual( - new Set([ - firstFanoutDelivery.messageId, - secondFanoutDelivery.messageId, - thirdFanoutDelivery.messageId, - ]), - ); - - // Keep completion delivery separate from the earlier fan-out cohorts. - const approvalParentGate = yield* Deferred.make(); - const approvalChildGate = yield* Deferred.make(); - parentTerminalGates.set(approvalParentId, approvalParentGate); - promptTerminalGates.set(approvalCompletionPrompt, approvalChildGate); - yield* Ref.set(continuationOffers, []); - const approvalStart = (yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:approval-parent:create"), - threadId: approvalParentId, - projectId, - title: "Approval parent", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - })).sequence; - const awaitApprovalEvent = ( - threadId: ThreadId, - matches: (event: OrchestrationV2DomainEvent) => boolean, - ) => - orchestrator.streamStoredEventsFrom({ threadId, afterSequence: approvalStart }).pipe( - Stream.filter((stored) => matches(stored.event)), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Approval scenario event stream ended."), - onSome: () => Effect.void, - }), - ), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:approval-parent:start"), - threadId: approvalParentId, - messageId: MessageId.make("message:approval-parent:start"), - text: "Hold this parent while its child asks for approval.", - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* awaitApprovalEvent( - approvalParentId, - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ); - const approvalScope = { - ...invocation, - requestNamespace: "mcp-approval-parent", - thread: { ...invocation.thread!, threadId: approvalParentId }, - }; - const delegateApproval = invokeAs(approvalScope, "delegate_task", { - task: approvalCompletionPrompt, - title: "Approval completion", - target: { providerInstanceId: codexInstanceId, model: codexModel }, - mode: "wait", - timeoutMs: 30_000, - clientRequestId: "delegate-approval-completion", - }); - const approvalWait = yield* delegateApproval.pipe(Effect.forkChild); - yield* awaitApprovalEvent( - approvalParentId, - (event) => event.type === "subagent.updated" && event.payload.childThreadId !== null, - ); - const approvalTask = (yield* orchestrator.getThreadProjection(approvalParentId)) - .subagents[0]!; - const approvalChildId = approvalTask.childThreadId!; - yield* awaitApprovalEvent( - approvalChildId, - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ); - const approvalChild = yield* orchestrator.getThreadProjection(approvalChildId); - const approvalRun = approvalChild.runs[0]!; - const approvalNode = approvalChild.nodes.find((node) => node.runId === approvalRun.id)!; - const approvalAskedAt = yield* DateTime.now; - const approvalRequest = ( - id: string, - overrides: Partial = {}, - ): OrchestrationV2RuntimeRequest => ({ - id: RuntimeRequestId.make(`request:approval:${id}`), - nodeId: approvalNode.id, - providerTurnId: null, - nativeRequestRef: null, - kind: "command", - status: "pending", - responseCapability: { - type: "live", - providerSessionId: approvalChild.providerSessions[0]!.id, - }, - createdAt: approvalAskedAt, - resolvedAt: null, - ...overrides, - }); - const eventSink = yield* EventSink.EventSinkV2; - let approvalEventId = 0; - const persistApprovalEvent = (event: OrchestrationV2DomainEvent) => - eventSink.write({ events: [event] }); - const persistRequest = ( - request: OrchestrationV2RuntimeRequest, - threadId = approvalChildId, - ) => - persistApprovalEvent({ - id: EventId.make(`event:approval:${++approvalEventId}`), - type: "runtime-request.updated", - threadId, - occurredAt: approvalAskedAt, - payload: request, - }); - const readApprovalStatus = () => - invokeAs(approvalScope, "task_status", { taskId: approvalTask.id }).pipe( - Effect.flatMap((result) => decodeDelegateTaskResult(result.structuredContent)), - Effect.orDie, - ); - - // These durable records must not make the parent's wait return an approval. - for (const request of [ - approvalRequest("resolved", { status: "resolved", resolvedAt: approvalAskedAt }), - approvalRequest("expired", { status: "expired", resolvedAt: approvalAskedAt }), - approvalRequest("cancelled", { status: "cancelled", resolvedAt: approvalAskedAt }), - approvalRequest("message", { responseCapability: { type: "message" } }), - approvalRequest("not-live", { - responseCapability: { type: "not_resumable", reason: "Session ended." }, - }), - approvalRequest("question", { - kind: "user_input", - responseCapability: { type: "message" }, - }), - approvalRequest("auth", { kind: "auth_refresh" }), - approvalRequest("tool", { kind: "dynamic_tool_call" }), - approvalRequest("missing-node", { nodeId: NodeId.make("node:missing-approval") }), - ]) { - yield* persistRequest(request); - } - expect((yield* readApprovalStatus()).pendingApproval).toBeNull(); - - // An unrelated untagged turn must not inherit the task's approval ownership. - const { delegatedTaskId: _, ...legacyRun } = approvalRun; - const foreignRun = { - ...legacyRun, - id: RunId.make("run:foreign-approval"), - ordinal: 2, - }; - const foreignNode = { - ...approvalNode, - id: NodeId.make("node:foreign-approval"), - runId: foreignRun.id, - }; - yield* eventSink.write({ - events: [ - { - id: EventId.make("event:foreign-approval-run"), - type: "run.updated", - threadId: approvalChildId, - occurredAt: approvalAskedAt, - payload: foreignRun, - }, - { - id: EventId.make("event:foreign-approval-node"), - type: "node.updated", - threadId: approvalChildId, - occurredAt: approvalAskedAt, - payload: foreignNode, - }, - ], - }); - yield* persistRequest(approvalRequest("foreign", { nodeId: foreignNode.id })); - expect((yield* readApprovalStatus()).pendingApproval).toBeNull(); - - const liveApproval = approvalRequest("live"); - yield* persistRequest(liveApproval); - const approvalResult = yield* decodeDelegateTaskResult( - (yield* Fiber.join(approvalWait)).structuredContent, - ).pipe(Effect.orDie); - const expectedApproval = { - requestId: liveApproval.id, - kind: "command", - runId: approvalRun.id, - }; - expect(approvalResult).toMatchObject({ - taskId: approvalTask.id, - status: "running", - hasPendingQuestion: false, - pendingApproval: expectedApproval, - waitTimedOut: false, - }); - expect(approvalResult.pendingApproval).toEqual(expectedApproval); - // A retry sees the persisted approval in its initial snapshot. - expect((yield* delegateApproval).structuredContent).toMatchObject({ - taskId: approvalTask.id, - status: "running", - pendingApproval: expectedApproval, - waitTimedOut: false, - }); - const earlierApproval = approvalRequest("earlier", { - createdAt: DateTime.subtract(approvalAskedAt, { seconds: 1 }), - }); - yield* persistRequest(earlierApproval); - expect((yield* readApprovalStatus()).pendingApproval?.requestId).toBe( - earlierApproval.id, - ); - yield* persistRequest({ - ...earlierApproval, - status: "resolved", - resolvedAt: approvalAskedAt, - }); - - // Legacy ownership falls back to the original run, never the other run. - yield* persistApprovalEvent({ - id: EventId.make("event:approval-legacy"), - type: "run.updated", - threadId: approvalChildId, - occurredAt: approvalAskedAt, - payload: legacyRun, - }); - expect((yield* readApprovalStatus()).pendingApproval).toEqual(expectedApproval); - yield* persistRequest({ - ...liveApproval, - status: "resolved", - resolvedAt: approvalAskedAt, - }); - expect((yield* readApprovalStatus()).pendingApproval).toBeNull(); - // Finish the synthetic foreign run before releasing the real child. - yield* eventSink.write({ - events: [ - { - id: EventId.make("event:approval-modern"), - type: "run.updated", - threadId: approvalChildId, - occurredAt: approvalAskedAt, - payload: approvalRun, - }, - { - id: EventId.make("event:foreign-approval-done"), - type: "run.updated", - threadId: approvalChildId, - occurredAt: approvalAskedAt, - payload: { ...foreignRun, status: "completed", completedAt: approvalAskedAt }, - }, - ], - }); - yield* Deferred.succeed(approvalChildGate, undefined); - yield* awaitApprovalEvent( - approvalParentId, - (event) => - event.type === "subagent.updated" && - event.payload.id === approvalTask.id && - event.payload.status === "completed", - ); - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - yield* Deferred.await(approvalOffered); - yield* worker.drain(); - const approvalCompletedTask = (yield* orchestrator.getThreadProjection( - approvalParentId, - )).subagents[0]!; - expect(approvalCompletedTask).toMatchObject({ - status: "completed", - completionWake: "always", - result: `Codex completed: ${approvalCompletionPrompt}`, - }); - const offers = (yield* Ref.get(continuationOffers)).filter( - (offer) => offer.threadId === approvalParentId, - ); - expect(offers).toHaveLength(1); - const approvalParent = yield* orchestrator.getThreadProjection(approvalParentId); - const approvalDelivery = approvalParent.runs[0]!.delegatedCompletion?.delivery; - expect(approvalDelivery?.taskIds).toEqual([approvalTask.id]); - expect(offers[0]?.delegatedCompletion?.messageId).toBe(approvalDelivery?.messageId); - expect(yield* readApprovalStatus()).toMatchObject({ - status: "completed", - summary: `Codex completed: ${approvalCompletionPrompt}`, - pendingApproval: null, - waitTimedOut: false, - }); - // Durable live records may outlast a terminal run or a stopped/disposed task. - yield* persistRequest(approvalRequest("after-completion")); - expect((yield* readApprovalStatus()).pendingApproval).toBeNull(); - yield* worker.drain(); - expect( - (yield* Ref.get(continuationOffers)).filter( - (offer) => offer.threadId === approvalParentId, - ), - ).toHaveLength(1); - yield* Deferred.succeed(approvalParentGate, undefined); - }).pipe(Effect.provide(layerTest)); - }), - ), - ); - it.live.each([ { label: "distinct", result: queuedFollowupResult, latestSummary: queuedFollowupResult }, { label: "identical", result: delegatedResult, latestSummary: null }, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index b41d34e9c231..6f55540ddcb6 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,12 +1,13 @@ +import * as Exit from "effect/Exit"; +import * as Scheduler from "effect/Scheduler"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, PreviewAutomationClientDisconnectedError, - PreviewAutomationInvalidSelectorError, PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, - PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, PreviewTabId, ProviderInstanceId, @@ -17,13 +18,10 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; -import * as Scheduler from "effect/Scheduler"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -202,40 +200,6 @@ it.effect.each([true, false])( ), ); -it.effect("tracks the tab returned by a targeted recording stop", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const browsingTabId = PreviewTabId.make("tab-session-b"); - const recordingTabId = PreviewTabId.make("tab-session-a-recording"); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: browsingTabId } - : request.operation === "recordingStop" - ? { id: "recording-1", tabId: recordingTabId } - : { url: "http://localhost:3200" }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: {} }); - yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(recordingTabId); - }), - ), -); - it.effect("does not let a no-tab response suppress an earlier tab decision", () => Effect.scoped( Effect.gen(function* () { @@ -327,192 +291,6 @@ it.effect("announces a live replacement stream before delivering requests", () = ), ); -it.effect( - "keeps a server-host open alive for installation without extending other operations", - () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const received = yield* Deferred.make(); - const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true })); - yield* Stream.runForEach(requests, (request) => - request.operation === "open" - ? Deferred.succeed(received, request) - : broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: request.timeoutMs, - }), - ).pipe(Effect.forkScoped); - const opening = yield* broker - .invoke({ scope, operation: "open", input: {} }) - .pipe(Effect.forkScoped); - const request = yield* Deferred.await(received); - yield* TestClock.adjust(16_000); - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "opened", - }); - expect(yield* Fiber.join(opening)).toBe("opened"); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe(15_000); - expect(yield* broker.invoke({ scope, operation: "navigate", input: {} })).toBe(15_000); - }), - ), -); - -it.effect("preserves bounded request and remote selector diagnostics", () => { - const locator = "role=button[name='request-secret']"; - const remoteMessage = "Unexpected token near remote-secret."; - const remoteError = { - _tag: "PreviewAutomationInvalidSelectorError", - message: remoteMessage, - detail: { selector: "role=button[name='remote-secret']" }, - } as const; - - return Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: remoteError, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ - scope, - operation: "click", - input: { locator }, - tabId: PreviewTabId.make("tab-1"), - timeoutMs: 1_234, - }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); - expect(error).toMatchObject({ - operation: "click", - environmentId: scope.environmentId, - threadId: scope.thread.threadId, - providerSessionId: scope.thread.providerSessionId, - providerInstanceId: scope.thread.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - tabId: "tab-1", - timeoutMs: 1_234, - selectorKind: "locator", - selectorLength: locator.length, - remoteTag: "PreviewAutomationInvalidSelectorError", - remoteMessageLength: remoteMessage.length, - remoteDetailKind: "object", - }); - expect(error.message).toBe( - `Preview automation click received an invalid locator (${locator.length} characters).`, - ); - expect(error.message).not.toContain("secret"); - expect(error.cause).toBe(remoteError); - expect("selector" in error).toBe(false); - expect("remoteMessage" in error).toBe(false); - expect("remoteDetail" in error).toBe(false); - }), - ); -}); - -it.effect("classifies a remote non-editable target without collapsing it to execution", () => { - const remoteError = { - _tag: "PreviewAutomationTargetNotEditableError", - message: "remote target details", - detail: { selectorKind: "focused-element" }, - } as const; - - return Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: remoteError, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ - scope, - operation: "type", - input: { text: "hello" }, - tabId: PreviewTabId.make("tab-1"), - }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError); - expect(error).toMatchObject({ - operation: "type", - tabId: "tab-1", - selectorKind: "focused-element", - remoteTag: "PreviewAutomationTargetNotEditableError", - }); - expect(error.message).toBe("Preview automation type requires an editable focused element."); - }), - ); -}); - -it.effect.each([ - "PreviewAutomationRecordingTransferError", - "PreviewAutomationRecordingDesktopUpdateRequiredError", - "PreviewAutomationRecordingTooLargeError", - "PreviewAutomationRecordingDeadlineExpiredError", -] as const)("preserves recording failure %s", (tag) => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const remoteError = { - _tag: tag, - message: "remote recording details", - detail: { reason: "untrusted-reason", threadId: "untrusted-thread" }, - }; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: remoteError, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const error = yield* broker - .invoke({ - scope, - operation: "recordingStop", - input: {}, - }) - .pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: tag, - threadId: scope.thread.threadId, - }); - expect(error.cause).toBe(remoteError); - expect(error.message).not.toContain("remote recording details"); - }), - ), -); - it.effect.each([ { clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, shown: true }, { clientId: "client-1", shown: false }, @@ -577,24 +355,6 @@ it.effect("distinguishes malformed remote failures", () => ), ); -it.effect("rejects calls when no connected host exists", () => - Effect.gen(function* () { - const broker = yield* makeBroker; - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.thread.threadId, - providerSessionId: scope.thread.providerSessionId, - providerInstanceId: scope.thread.providerInstanceId, - }); - }), -); - it.effect("does not create host state from focus updates without a live stream", () => Effect.gen(function* () { const broker = yield* makeBroker; @@ -890,63 +650,6 @@ it.effect("prefers the live tab owner for new sessions without moving existing l ), ); -it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let focusedConnectionId = ""; - for (const [clientId, supportedOperations] of [ - ["focused", ["status"]], - ["background", ["status", "resize"]], - ] as const) { - const requests = requestsFrom( - yield* broker.connect(makeHost({ clientId, supportedOperations })), - (connectionId) => { - if (clientId === "focused") focusedConnectionId = connectionId; - }, - ); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId, - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: clientId, - }), - ).pipe(Effect.forkScoped); - } - yield* Effect.yieldNow; - yield* broker.focusHost({ - clientId: "focused", - environmentId: scope.environmentId, - connectionId: focusedConnectionId, - focused: true, - }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "focused", - ); - }), - ), -); - -it.effect("does not route new operations to legacy hosts that did not advertise support", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const legacyEvents = yield* broker.connect(makeHost()); - yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ scope, operation: "resize", input: { mode: "fill" } }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId }); - }), - ), -); - it.effect("routes resize to a capable host instead of a newer legacy connection", () => Effect.scoped( Effect.gen(function* () { @@ -1143,30 +846,6 @@ it.effect("fails over a pinned provider session only after its host disconnects" ), ); -it.effect("lets the browser host resolve an active tab locally", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - let routedTabId: string | undefined; - yield* Stream.runForEach(requests, (request) => { - routedTabId = request.tabId; - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); - - expect(routedTabId).toBeUndefined(); - }), - ), -); - it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => Effect.scoped( Effect.gen(function* () { @@ -1318,114 +997,6 @@ it.effect("accepts responses only from the host that received the request", () = ), ); -it.effect("discards buffered actions before completing an evicted host stream", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const connected = yield* Deferred.make(); - const received = yield* Deferred.make(); - const actionRouted = yield* Deferred.make(); - const releaseConsumer = yield* Deferred.make(); - const operations: string[] = []; - const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => { - if (event.type === "connected") return Deferred.succeed(connected, undefined); - operations.push(event.request.operation); - return Deferred.succeed(received, undefined).pipe( - Effect.andThen(Deferred.await(releaseConsumer)), - ); - }).pipe(Effect.forkScoped); - yield* Deferred.await(connected); - const timedOut = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(received); - const buffered = yield* broker - .invoke({ - scope, - operation: "click", - input: {}, - timeoutMs: 10_000, - onTargetTab: () => { - Deferred.doneUnsafe(actionRouted, Effect.void); - }, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(actionRouted); - yield* TestClock.adjust(2_000); - expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); - expect(yield* Fiber.join(buffered)).toMatchObject({ - _tag: "PreviewAutomationClientDisconnectedError", - }); - yield* Deferred.succeed(releaseConsumer, undefined); - expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); - expect(operations).toEqual(["snapshot"]); - }), - ), -); - -it.effect("rejects a routed action when its generation is evicted before delivery", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const connected = yield* Deferred.make(); - const received = yield* Deferred.make(); - const actionRouted = yield* Deferred.make(); - const operations: string[] = []; - const consumer = yield* Stream.runForEach(yield* broker.connect(makeHost()), (event) => { - if (event.type === "connected") return Deferred.succeed(connected, undefined); - operations.push(event.request.operation); - return Deferred.succeed(received, undefined); - }).pipe(Effect.forkScoped); - yield* Deferred.await(connected); - const timedOut = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, timeoutMs: 1_000 }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(received); - - const tasks: Array<() => void> = []; - let paused = false; - const dispatcher: Scheduler.SchedulerDispatcher = { - scheduleTask: (task) => tasks.push(task), - flush: () => { - let task: (() => void) | undefined; - while ((task = tasks.shift()) !== undefined) task(); - }, - }; - const scheduler: Scheduler.Scheduler = { - executionMode: "async", - makeDispatcher: () => dispatcher, - shouldYield: () => paused, - }; - const action = yield* broker - .invoke({ - scope, - operation: "click", - input: {}, - onTargetTab: () => { - paused = true; - Deferred.doneUnsafe(actionRouted, Effect.void); - }, - }) - .pipe( - Effect.flip, - Effect.provideService(Scheduler.Scheduler, scheduler), - Effect.forkScoped, - ); - yield* Deferred.await(actionRouted); - yield* TestClock.adjust(2_000); - expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); - expect(Exit.isSuccess(yield* Fiber.await(consumer))).toBe(true); - - paused = false; - dispatcher.flush(); - expect(yield* Fiber.join(action)).toMatchObject({ - _tag: "PreviewAutomationClientDisconnectedError", - }); - expect(operations).toEqual(["snapshot"]); - }), - ), -); - it.effect.each(["snapshot", "evaluate", "navigate"] as const)( "retains an opted-in host, pending open, and session tabs after a %s timeout", (operation) => @@ -1750,82 +1321,6 @@ it.effect.each([true, false])( ), ); -it.effect("keeps a host whose operation timeout arrives just after the budget", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const received = yield* Deferred.make(); - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - Effect.gen(function* () { - if (request.operation === "waitFor") { - yield* Deferred.succeed(received, undefined); - yield* Effect.sleep(request.timeoutMs + 50); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ...(request.operation === "waitFor" - ? { - ok: false, - error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, - } - : { ok: true, result: "responsive" }), - }); - }), - ).pipe(Effect.forkScoped); - const timedOut = yield* broker - .invoke({ scope, operation: "waitFor", input: {}, timeoutMs: 1_000 }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(received); - yield* TestClock.adjust(1_050); - expect(yield* Fiber.join(timedOut)).toMatchObject({ - _tag: "PreviewAutomationTimeoutError", - remoteTag: "PreviewAutomationTimeoutError", - }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive"); - }), - ), -); - -it.effect("keeps the host connected when a background status read times out", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - request.operation === "status" - ? Effect.void - : broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { operation: request.operation }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const status = yield* broker - .invoke({ - scope, - operation: "status", - input: {}, - timeoutMs: 500, - updateCurrentTab: false, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* TestClock.adjust(500); - expect(yield* Fiber.join(status)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); - - expect(yield* broker.invoke({ scope, operation: "snapshot", input: {} })).toEqual({ - operation: "snapshot", - }); - }), - ), -); - // Leave transport delivery separate from execution so these tests can hold // a host event without starting a fresh timeout at dequeue. const makeGuardedRequest = Effect.gen(function* () { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts b/apps/server/src/mcp/ThreadMetadataMcpService.test.ts deleted file mode 100644 index 411568f75745..000000000000 --- a/apps/server/src/mcp/ThreadMetadataMcpService.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; -import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; -import type * as McpInvocationContext from "./McpInvocationContext.ts"; -import * as ThreadMetadataMcp from "./ThreadMetadataMcpService.ts"; - -const threadId = ThreadId.make("thread:metadata-caller"); -const scope: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("environment:metadata-test"), - requestNamespace: "provider-session:metadata-test", - thread: { - threadId, - providerSessionId: "provider-session:metadata-test", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(["orchestration"]), - issuedAt: 1, -}; - -function layerService( - getThreadShell: ThreadManagement.ThreadManagementService["Service"]["getThreadShell"], -) { - return ThreadMetadataMcp.layer.pipe( - Layer.provide( - Layer.merge( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell, - getThreadRecords: () => Effect.die("projection must not load after shell failure"), - } satisfies Partial), - NodeCrypto.layer, - ), - ), - ); -} - -const updateCallingThread = Effect.gen(function* () { - const service = yield* ThreadMetadataMcp.ThreadMetadataMcpService; - return yield* service.update(scope, { - action: "rename", - title: "Renamed thread", - clientRequestId: "metadata-caller-classification", - }); -}); - -it.effect("reports an absent calling thread as thread_not_found", () => - Effect.gen(function* () { - const error = yield* updateCallingThread.pipe( - Effect.provide(layerService(() => Effect.succeed(null))), - Effect.flip, - ); - - expect(error.code).toBe("thread_not_found"); - }), -); - -it.effect("keeps calling-thread storage failures as orchestration errors", () => - Effect.gen(function* () { - const error = yield* updateCallingThread.pipe( - Effect.provide( - layerService(() => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: new Error("storage unavailable"), - }), - ), - ), - ), - Effect.flip, - ); - - expect(error.code).toBe("orchestration_error"); - }), -); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 647823016450..bab22c76af26 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -375,81 +375,6 @@ const runStatus = (harness: ReturnType) => const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); describe("t3_worktree_handoff", () => { - it.effect("creates a worktree from the current branch and re-points the thread", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { branch: "feature/handoff" }); - - expect(result.branch).toBe("feature/handoff"); - expect(result.baseRef).toBe("dev"); - expect(result.startedFromOrigin).toBe(false); - expect(result.worktreePath).toBe("/worktrees/project/feature/handoff"); - expect(result.setupScript).toMatchObject({ - status: "started", - scriptName: "Setup", - }); - - expect(harness.fetchWorktreeBaseCommit).not.toHaveBeenCalled(); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: workspaceRoot, - refName: "dev", - newRefName: "feature/handoff", - baseRefName: "dev", - path: null, - }); - expect(harness.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: "thread.metadata.update", - threadId, - branch: "feature/handoff", - worktreePath: "/worktrees/project/feature/handoff", - expectedWorktreePath: null, - }), - ); - expect(harness.runForThread).toHaveBeenCalledWith({ - threadId, - projectId, - projectCwd: workspaceRoot, - worktreePath: "/worktrees/project/feature/handoff", - project: { id: projectId, workspaceRoot, scripts: [] }, - }); - }); - }); - - it.effect("skips the continuation when no continuationPrompt is given", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { branch: "feature/no-continuation" }); - expect(result.continuation).toEqual({ status: "skipped" }); - expect(harness.sendToThread).not.toHaveBeenCalled(); - }); - }); - - it.effect("queues the continuation prompt as the thread's next message", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { - branch: "feature/continue", - continuationPrompt: "Keep fixing the login bug in the new worktree.", - }); - - expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" }); - expect(harness.sendToThread).toHaveBeenCalledWith( - expect.objectContaining({ - projectId, - threadId, - text: "Keep fixing the login bug in the new worktree.", - mode: "queue", - createdBy: "agent", - creationSource: "mcp", - }), - ); - expect(harness.sendToThread.mock.invocationCallOrder[0]).toBeLessThan( - harness.runForThread.mock.invocationCallOrder[0]!, - ); - }); - }); - it.effect("reports a continuation failure without failing the handoff", () => { const harness = makeHarness({ continuation: "fails" }); return Effect.gen(function* () { @@ -463,18 +388,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("reports a continuation defect without failing the handoff", () => { - const harness = makeHarness({ continuation: "dies" }); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { - branch: "feature/continue-dies", - continuationPrompt: "Keep going.", - }); - expect(result.continuation).toEqual({ status: "failed", detail: "send defect" }); - expect(result.setupScript).toMatchObject({ status: "started" }); - }); - }); - it.effect("does not queue a continuation when the thread update fails", () => { const harness = makeHarness({ dispatchFails: true }); return Effect.gen(function* () { @@ -661,60 +574,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("fails when the thread does not exist", () => { - const harness = makeHarness({ thread: null }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/missing" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("treats a soft-deleted thread as not found", () => { - const harness = makeHarness({ thread: { deletedAt: "2026-01-02T00:00:00.000Z" } }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/deleted" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("maps a non-projection orchestrator error to operation_failed", () => { - const harness = makeHarness({ threadReadError: "dispatch" }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/read-error" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("fails when the project does not exist", () => { - const harness = makeHarness({ projectMissing: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-project" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "project_not_found" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("maps a project read error to operation_failed", () => { - const harness = makeHarness({ projectReadFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/project-error" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("fails when the project workspace is not a git repository", () => { - const harness = makeHarness({ notARepo: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-repo" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - it.effect("rejects an existing branch with an actionable error naming its checkout", () => { const harness = makeHarness({ existingBranchWorktreePath: "/elsewhere/checkout" }); return Effect.gen(function* () { @@ -735,15 +594,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("rejects an existing branch that is not checked out anywhere", () => { - const harness = makeHarness({ existingBranchWorktreePath: null }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/taken-idle" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - it.effect("does not trip the pre-flight on similarly named branches", () => { const harness = makeHarness({ existingBranchWorktreePath: null }); return Effect.gen(function* () { @@ -753,17 +603,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("rejects a non-repository workspace even when baseRef is explicit", () => { - const harness = makeHarness({ notARepo: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - runHandoff(harness, { branch: "feature/no-repo-baseref", baseRef: "dev" }), - ); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - it.effect("propagates an interrupted dispatch without rolling back the worktree", () => { const harness = makeHarness({ dispatchInterrupts: true }); return Effect.gen(function* () { @@ -776,27 +615,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("maps a worktree creation failure to operation_failed", () => { - const harness = makeHarness({ createWorktreeFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/create-fails" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.dispatch).not.toHaveBeenCalled(); - expect(harness.removeWorktree).not.toHaveBeenCalled(); - }); - }); - - it.effect("maps a remote base fetch failure to operation_failed", () => { - const harness = makeHarness({ fetchWorktreeBaseFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - runHandoff(harness, { branch: "feature/fetch-fails", startFromOrigin: true }), - ); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - it.effect("falls back to the local base when no remote holds it", () => { const harness = makeHarness({ worktreeBaseMissing: true }); return Effect.gen(function* () { @@ -873,15 +691,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("rejects a handoff for an archived thread", () => { - const harness = makeHarness({ thread: { archivedAt: "2026-01-02T00:00:00.000Z" } }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/archived" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); - expect(harness.createWorktree).not.toHaveBeenCalled(); - }); - }); - it.effect("queues the continuation even when interrupted during the binding dispatch", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -906,32 +715,6 @@ describe("t3_worktree_handoff", () => { }), ); - it.effect("still fails with a typed error when the rollback removal also fails", () => { - const harness = makeHarness({ dispatchFails: true, removeWorktreeFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/rollback-fails" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.removeWorktree).toHaveBeenCalledTimes(1); - expect(harness.deleteLocalBranch).not.toHaveBeenCalled(); - }); - }); - - it.effect("preserves the typed failure when rollback branch deletion also fails", () => { - const harness = makeHarness({ dispatchFails: true, deleteLocalBranchFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - runHandoff(harness, { branch: "feature/rollback-branch-fails" }), - ); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.removeWorktree).toHaveBeenCalledTimes(1); - expect(harness.deleteLocalBranch).toHaveBeenCalledWith({ - cwd: workspaceRoot, - refName: "feature/rollback-branch-fails", - force: true, - }); - }); - }); - it.effect("creates the worktree in the managed directory even when a call names a path", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -963,14 +746,6 @@ describe("t3_worktree_handoff", () => { }); }); - it.effect("fails when the worktree capability is missing", () => { - const harness = makeHarness({ capabilities: new Set(["preview"]) }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/no-capability" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "capability_denied" }); - }); - }); - it.effect("serializes concurrent handoffs for the same thread", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -995,37 +770,6 @@ describe("t3_worktree_handoff", () => { }), ); - it.effect("removes the created worktree when the thread update fails", () => { - const harness = makeHarness({ dispatchFails: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/dispatch-fails" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" }); - expect(harness.createWorktree).toHaveBeenCalledTimes(1); - expect(harness.removeWorktree).toHaveBeenCalledWith({ - cwd: workspaceRoot, - path: "/worktrees/project/feature/dispatch-fails", - force: true, - }); - }); - }); - - it.effect("fails when baseRef is omitted and HEAD is detached", () => { - const harness = makeHarness({ currentBranch: null }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/detached" })); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); - }); - }); - - it.effect("reports setup script failure without failing the handoff", () => { - const harness = makeHarness({ setupScript: "fails" }); - return Effect.gen(function* () { - const result = yield* runHandoff(harness, { branch: "feature/setup-fails" }); - expect(result.setupScript.status).toBe("failed"); - expect(harness.dispatch).toHaveBeenCalled(); - }); - }); - it.effect("reports a setup script defect without failing the handoff", () => { const harness = makeHarness({ setupScript: "dies" }); return Effect.gen(function* () { @@ -1037,20 +781,6 @@ describe("t3_worktree_handoff", () => { }); describe("t3_worktree_status", () => { - it.effect("reports an unattached thread", () => { - const harness = makeHarness({ newWorktreesStartFromOrigin: true }); - return Effect.gen(function* () { - const result = yield* runStatus(harness); - expect(result).toEqual({ - attached: false, - worktreePath: null, - branch: null, - projectWorkspaceRoot: workspaceRoot, - defaultStartFromOrigin: true, - }); - }); - }); - it.effect("reports an attached thread's worktree and branch", () => { const harness = makeHarness({ thread: { @@ -1068,46 +798,4 @@ describe("t3_worktree_status", () => { }); }); }); - - it.effect("fails when the worktree capability is missing", () => { - const harness = makeHarness({ capabilities: new Set(["preview"]) }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runStatus(harness)); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "capability_denied" }); - }); - }); - - it.effect("fails when the thread does not exist", () => { - const harness = makeHarness({ thread: null }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runStatus(harness)); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "thread_not_found" }); - }); - }); - - it.effect("fails when the project does not exist", () => { - const harness = makeHarness({ projectMissing: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(runStatus(harness)); - expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "project_not_found" }); - }); - }); -}); - -describe("WorktreeMcpHandoffInput schema", () => { - const decode = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); - - it.effect("rejects a missing or blank branch", () => - Effect.gen(function* () { - expect(Exit.isFailure(yield* Effect.exit(decode({})))).toBe(true); - expect(Exit.isFailure(yield* Effect.exit(decode({ branch: " " })))).toBe(true); - }), - ); - - it.effect("rejects a blank continuationPrompt", () => - Effect.gen(function* () { - const exit = yield* Effect.exit(decode({ branch: "feature/x", continuationPrompt: " " })); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); }); diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 5cb069c589b8..74e14453c4ea 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,8 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; -import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation"; import { DEFAULT_SERVER_SETTINGS, ChatImageAttachment, @@ -21,7 +19,6 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -29,7 +26,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { McpAttachmentInput } from "./attachment/input.ts"; -import { McpSchema, McpServer, Tool } from "effect/ai"; +import { McpSchema, McpServer } from "effect/ai"; import { FetchHttpClient } from "effect/http"; import { @@ -59,18 +56,8 @@ import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; import { dispatchFailure } from "../threadAccess.ts"; -import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; -import { OrchestratorToolkit } from "./orchestrator/tools.ts"; -import { PreviewToolkit } from "./preview/tools.ts"; -import { PreviewControlsToolkit } from "./previewControls/tools.ts"; -import { EnvironmentToolkit } from "./environment/tools.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; -import { ProjectToolkit } from "./project/tools.ts"; -import { AttachmentToolkit } from "./attachment/tools.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; -import { ThreadToolkit } from "./thread/tools.ts"; -import { WorktreeToolkit } from "./worktree/tools.ts"; -import { DeviceToolkit } from "./device/tools.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -84,15 +71,6 @@ const declaredFailure = (result: McpSchema.CallToolResult) => { const text = result.content[0]; return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; }; -import { IssueLinksToolkit } from "./issueLinks/tools.ts"; -import { MessageDraftsToolkit } from "./messageDrafts/tools.ts"; -import { PullRequestsToolkit } from "./pullRequests/tools.ts"; -import { HtmlToolkit } from "./html/tools.ts"; -import { - resolveT3McpToolDefinition, - resolveT3McpToolPresentation, - resolveT3McpToolSummaryAction, -} from "@t3tools/shared/t3McpToolPresentation"; import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); @@ -117,45 +95,6 @@ const layerThreadToolkitWith = ( ); const layerThreadToolkit = layerThreadToolkitWith(layerThreadMoveServices); -it("publishes unique tool names with reference-free object-root inputs", () => { - const names = new Set(); - for (const toolkit of [ - OrchestratorToolkit, - PreviewToolkit, - WorktreeToolkit, - ThreadToolkit, - AttachmentToolkit, - ProjectToolkit, - EnvironmentToolkit, - PreviewControlsToolkit, - DeviceToolkit, - PullRequestsToolkit, - IssueLinksToolkit, - MessageDraftsToolkit, - HtmlToolkit, - ]) { - for (const tool of Object.values(toolkit.tools)) { - expect(names.has(tool.name)).toBe(false); - names.add(tool.name); - const schema = Tool.getJsonSchema(tool); - expect(schema).toMatchObject({ type: "object" }); - expect(JSON.stringify(schema), tool.name).not.toContain('"$ref"'); - const definition = resolveT3McpToolDefinition(tool.name); - expect(definition, tool.name).not.toBeNull(); - expect( - definition?.labels.every((label) => label.trim().length > 0), - tool.name, - ).toBe(true); - for (const name of [tool.name, `mcp__t3-code__${tool.name}`, `T3-code.${tool.name}`]) { - expect(resolveT3McpToolPresentation(name)?.logo, name).toBe("t3-code"); - expect(resolveT3McpToolSummaryAction(name), name).not.toBeNull(); - } - } - } - expect(names.has("t3_thread_launch")).toBe(true); - expect(names.has("t3_thread_start")).toBe(false); -}); - const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("mcp-core-environment"), @@ -209,57 +148,6 @@ it.effect("checks capability through the production registration", () => ), ); -it.effect("returns a bounded public failure without serializing storage causes", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(declaredFailure(result)).toEqual({ - _tag: "OrchestratorMcpFailure", - code: "orchestration_error", - message: "The operation could not be completed.", - }); - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: "text", - text: '{"_tag":"OrchestratorMcpFailure","code":"orchestration_error","message":"The operation could not be completed."}', - }, - ]); - const definition = server.tools.find(({ tool }) => tool.name === "t3_thread_organize"); - expect(definition?.tool.outputSchema).toBeDefined(); - const validate = new AjvJsonSchemaValidator().getValidator( - definition!.tool.outputSchema! as JsonSchemaType, - ); - expect(result.structuredContent).toBeUndefined(); - expect(validate({ sequence: 1 }).valid).toBe(true); - expect(validate({ code: "orchestration_error" }).valid).toBe(false); - expect(validate({ sequence: "invalid" }).valid).toBe(false); - }).pipe( - Effect.provide( - layerThreadToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: new Error("private-storage-path"), - }), - ), - }), - ), - ), - ), - ), -); - it("bounds public command rejections and redacts internal dispatch causes", () => { const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" }; expect( @@ -342,28 +230,6 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both ), ); -it.effect("returns invalid parameter errors through the production registration", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const error = yield* server - .callTool({ name: "t3_thread_organize", arguments: { action: "invalid" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provideService(McpSchema.McpServerClient, client), - Effect.flip, - ); - expect(error._tag).toBe("InvalidParams"); - }).pipe( - Effect.provide( - layerThreadToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), - ), - ), - ), -); - it.effect("keeps unexpected handler defects private through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; @@ -393,132 +259,6 @@ it.effect("keeps unexpected handler defects private through the production regis ), ); -it.effect("records the calling agent and the agent a tool acted on", () => { - // Recording runs detached from the tool response, so each expected event is awaited. - const recorded = [ - Deferred.makeUnsafe<{ event: string; properties: object }>(), - Deferred.makeUnsafe<{ event: string; properties: object }>(), - ]; - let recordedCount = 0; - const shell = (id: ThreadId, instanceId: string, model: string) => - ({ - id, - projectId: "mcp-core-project", - providerInstanceId: ProviderInstanceId.make(instanceId), - modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model }, - runtimeMode: "full-access", - interactionMode: "default", - archivedAt: null, - deletedAt: null, - activeRunId: "mcp-core-run", - createdBy: "agent", - creationSource: "mcp", - lineage: { - parentThreadId: id === threadId ? ThreadId.make("mcp-core-parent") : null, - relationshipToParent: id === threadId ? "subagent" : null, - rootThreadId: ThreadId.make("mcp-core-parent"), - }, - }) as never; - return Effect.gen(function* () { - const server = yield* McpServer.McpServer; - yield* server - .callTool({ name: "t3_thread_fork", arguments: { sourcePoint: { type: "latest_stable" } } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provideService(McpSchema.McpServerClient, client), - ); - const { event, properties } = yield* Deferred.await(recorded[0]!); - expect(event).toBe("mcp.tool.invoked"); - expect(properties).toMatchObject({ - tool: "t3_thread_fork", - outcome: "ok", - callerProvider: "codex", - callerModel: "gpt-5.5", - callerOrigin: "agent", - callerDepth: 1, - targetProvider: "claudeAgent", - targetModel: "claude-opus-5-5", - targetRuntimeMode: "full-access", - targetInteractionMode: "default", - crossProvider: true, - }); - expect(properties).toHaveProperty("durationMs"); - expect(Object.values(properties).some((value) => String(value).includes("mcp-core"))).toBe( - false, - ); - - // Queue tools return only a sequence, so the receiving thread comes from the input. - yield* server - .callTool({ - name: "t3_queue_edit", - arguments: { threadId: "mcp-core-other", queuedRunId: "mcp-core-queued", text: "secret" }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provideService(McpSchema.McpServerClient, client), - ); - // The fork recorded a single event, so the next one is the queue edit. - expect((yield* Deferred.await(recorded[1]!)).properties).toMatchObject({ - tool: "t3_queue_edit", - outcome: "ok", - callerProvider: "codex", - targetProvider: "claudeAgent", - crossProvider: true, - }); - }).pipe( - Effect.provide( - layerThreadToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: (id) => - Effect.succeed( - id === threadId - ? shell(threadId, "codex", "gpt-5.5") - : shell(id, "claudeAgent", "claude-opus-5-5"), - ), - getThreadRecords: () => Effect.succeed({ runs: [], messages: [] } as never), - getProjectThreadRecords: () => - Effect.succeed({ thread: shell(threadId, "codex", "gpt-5.5") } as never), - dispatch: () => Effect.succeed({ sequence: 1 } as never), - }), - ), - Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - { - instanceId: ProviderInstanceId.make("codex"), - driver: "codex", - models: [{ slug: "gpt-5.5", isCustom: false }], - } as never, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: "claudeAgent", - models: [{ slug: "claude-opus-5-5", isCustom: false }], - } as never, - ]), - }), - ), - Layer.provide( - Layer.succeed( - AnalyticsService.AnalyticsService, - AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - Deferred.succeed(recorded[recordedCount++]!, { - event, - properties: properties ?? {}, - }).pipe(Effect.asVoid), - flush: Effect.void, - recordOwners: () => Effect.void, - }), - ), - ), - ), - ), - ); -}); - it("keeps MCP preference output allowlisted and Unicode-bounded", () => { const settings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 110d4cbba72a..0ad4b95581b2 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { agentDeviceQuickStart, agentDeviceTargetArgs, pngDimensions } from "./handlers.ts"; +import { agentDeviceQuickStart, pngDimensions } from "./handlers.ts"; const device = { hostId: "local", @@ -13,23 +13,6 @@ const device = { }; describe("device tool helpers", () => { - it("pins agent-device commands to the device by platform-specific flag", () => { - expect(agentDeviceTargetArgs(device)).toEqual(["--platform", "ios", "--udid", "ABCD-1234"]); - expect(agentDeviceTargetArgs({ ...device, platform: "android", id: "emulator-5554" })).toEqual([ - "--platform", - "android", - "--serial", - "emulator-5554", - ]); - }); - - it("writes the quick start around the pinned target", () => { - const text = agentDeviceQuickStart(device); - expect(text).toContain("agent-device snapshot -i --platform ios --udid ABCD-1234"); - expect(text).toContain("iPhone 17 Pro (iOS 27.0)"); - expect(text).toContain("XCTest runner"); - }); - it("uses the absolute launcher in every quick-start command", () => { const text = agentDeviceQuickStart( device, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 4c06fbb87845..8fdbd264b872 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -20,7 +20,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; -export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { +function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { return device.platform === "ios" ? ["--platform", "ios", "--udid", device.id] : ["--platform", "android", "--serial", device.id]; diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index c870a7354bcd..b4d39bd41c17 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -12,13 +12,11 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import { McpSchema, McpServer } from "effect/ai"; import * as Environment from "../../../environment/ServerEnvironment.ts"; import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; import * as Settings from "../../../serverSettings.ts"; -import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; @@ -27,71 +25,6 @@ import { EnvironmentToolkit } from "./tools.ts"; const environmentId = EnvironmentId.make("environment:preferences"); const threadId = ThreadId.make("thread:preferences"); -const caller: McpInvocationContext.McpInvocationScope = { - environmentId, - requestNamespace: "provider:preferences", - thread: { - threadId, - providerSessionId: "provider:preferences", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(["orchestration" as const]), - issuedAt: 0, -}; -const layerEnvironment = Layer.mock(Environment.ServerEnvironment)({ - getDescriptor: Effect.succeed({ - environmentId, - label: "Test", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0", - capabilities: { repositoryIdentity: false }, - }), -}); - -it.effect("updates preferences for a thread caller through the /mcp registration", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ - name: "t3_environment_preferences_update", - arguments: { newWorktreesStartFromOrigin: true }, - }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, caller), - Effect.provideService( - McpSchema.McpServerClient, - McpSchema.McpServerClient.of({ - clientId: 1, - protocolVersion: "2025-06-18", - clientCapabilities: {}, - clientInfo: { name: "preferences", version: "1" }, - initializePayload: { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "preferences", version: "1" }, - }, - getClient: Effect.die("unused"), - }), - ), - ); - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ newWorktreesStartFromOrigin: true }); - }).pipe( - Effect.provide( - McpHttpServer.layerEnvironmentToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: (id) => Effect.succeed(liveThreadShell(id)), - }), - ), - Layer.provide(layerEnvironment), - Layer.provide(Settings.layerTest()), - ), - ), - ), -); it.effect("refuses a preferences update when the caller's turn ends while it waits", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts index 0a68b289d9ef..381a6257f3b1 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.test.ts @@ -1,13 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; import { Tool } from "effect/ai"; -import { - CreateThreadsTool, - DelegateTaskTool, - OrchestratorToolkit, - ScheduleTaskTool, - ThreadUpdateTool, -} from "./tools.ts"; +import { OrchestratorToolkit } from "./tools.ts"; +const { + create_threads: CreateThreadsTool, + delegate_task: DelegateTaskTool, + schedule_task: ScheduleTaskTool, + t3_thread_update: ThreadUpdateTool, +} = OrchestratorToolkit.tools; describe("orchestrator MCP tool guidance", () => { it("directs subagent requests to delegation instead of ordinary threads", () => { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2ae547140914..f82b47080450 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -68,7 +68,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -export const DelegateTaskTool = Tool.make("delegate_task", { +const DelegateTaskTool = Tool.make("delegate_task", { description: "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Use delegate_task when model options are requested or the child keeps the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities, even for native-supported models. Native subagents cannot reliably apply T3 model options. Set target.options using advertised IDs: Claude {effort: 'max', fastMode: true}; Codex {reasoningEffort: 'max', serviceTier: 'priority'}. Check providerInstanceId, model, and options in the result. Provider/model and modes inherit unless overridden. Options inherit only for the same instance and model; explicit entries override individual values and [] or {} clears them. When this thread has stopAtSubscriptionLimit on, the child gets it too on any model that offers it, and it cannot be turned off for the child. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Alternatively, workspaceStrategy gives the child its own checkout (choose only one): worktree creates a new worktree from baseRef, existing_worktree binds one, which may belong to any T3 project and runs that project's setup. Use existing_worktree, not a native subagent told to cd, when the work lives in another checkout or repository. The app prepares it before the child starts; preparation failure fails the task with the reason in summary. Do not ask the child to create a worktree in its prompt. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, a pending approval, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. If pendingApproval is set, read the fresh full request in childThreadId before responding: the record can outlive its provider callback. It grants no approval authority; decisions remain with the child's existing approval holder, and pending request tools answer questions only. The child keeps running and its completion still wakes this thread. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, @@ -119,9 +119,9 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { .annotate(Tool.Title, "Stop a workflow") .annotate(Tool.Destructive, true); -export const ScheduleTaskTool = Tool.make("schedule_task", { +const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -148,7 +148,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", { const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", { description: - "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run.", + "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.", parameters: OrchestratorMcpUpdateScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -182,7 +182,7 @@ const RequestSecretTool = Tool.make("request_secret", { .annotate(Tool.Title, "Request a secret from the user") .annotate(Tool.Destructive, false); -export const CreateThreadsTool = Tool.make("create_threads", { +const CreateThreadsTool = Tool.make("create_threads", { description: "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model and no model options are requested or inherited; otherwise call delegate_task, including for same-provider work. This includes keeping the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy, or delegate_task with workspaceStrategy for a subagent, instead of asking the agent to create a worktree in its prompt.", parameters: OrchestratorMcpCreateThreadsInput, @@ -224,7 +224,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -export const ThreadUpdateTool = Tool.make("t3_thread_update", { +const ThreadUpdateTool = Tool.make("t3_thread_update", { description: "Update metadata for a thread. Omit threadId to update this thread. Use action='rename' with title, action='regenerate_title' with no extra field, action='link_pull_request' with pullRequest, or action='unlink_pull_request'. Workspace and branch changes are intentionally not supported. clientRequestId makes retries idempotent.", parameters: ThreadMetadataMcpUpdateInput, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index a2a88e14fcef..c46e35851808 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -11,36 +11,7 @@ import { parseThreadSegmentFromAttachmentId, } from "../../../attachmentStore.ts"; import * as ServerConfig from "../../../config.ts"; -import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts"; - -describe("normalizePreviewOpenInput", () => { - it("leaves an unstated visibility for the client preference to decide", () => { - // Filling `open` in here would outrank `browserAutoShowFloatingPreview`, - // which is desktop-local and cannot be read from the server. - expect(normalizePreviewOpenInput({})).toEqual({ reuseExistingTab: true }); - }); - - it("preserves an explicit background-only opt-out", () => { - expect(normalizePreviewOpenInput({ open: false })).toEqual({ - open: false, - reuseExistingTab: true, - show: false, - }); - }); - - it("supports show as a legacy alias while preferring open", () => { - expect(normalizePreviewOpenInput({ show: false })).toEqual({ - open: false, - reuseExistingTab: true, - show: false, - }); - expect(normalizePreviewOpenInput({ open: true, show: false })).toEqual({ - open: true, - reuseExistingTab: true, - show: true, - }); - }); -}); +import { claimPreviewRecording } from "./handlers.ts"; describe("claimPreviewRecording", () => { it.effect("overlapping and repeated claims return the same retained recording", () => @@ -129,27 +100,4 @@ describe("claimPreviewRecording", () => { ), ), ); - - it.effect("reports an older desktop without returning its inaccessible path", () => - Effect.gen(function* () { - const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), { - id: "desktop-recording", - tabId: "tab-1", - path: "/desktop/recording.webm", - mimeType: "video/webm", - sizeBytes: 6, - createdAt: "2026-09-07T00:00:00.000Z", - }).pipe(Effect.result); - expect(result._tag).toBe("Failure"); - if (result._tag !== "Failure") return; - expect(result.failure._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError"); - expect(result.failure.message).toContain("Update the desktop app"); - }).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( - Layer.provideMerge(NodeServices.layer), - ), - ), - ), - ); }); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index de9238494ab1..a2c11ff32d81 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -38,9 +38,7 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from " * preference, which is desktop-local and unreadable from here — filling in * `true` would silently override it for every `preview_open`. */ -export function normalizePreviewOpenInput( - input: PreviewAutomationOpenInput, -): PreviewAutomationOpenInput { +function normalizePreviewOpenInput(input: PreviewAutomationOpenInput): PreviewAutomationOpenInput { const open = input.open ?? input.show; return { ...input, diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 4212c711c3f2..6275e8cf41a4 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -55,26 +55,3 @@ it("exports provider-compatible object schemas with described parameters", () => } } }); - -it("exports exact object result schemas for preview actions", () => { - const actionNames = [ - "preview_click", - "preview_type", - "preview_hover", - "preview_drag", - "preview_upload", - "preview_press", - "preview_scroll", - "preview_wait_for", - ] as const; - for (const name of actionNames) { - // Effect's tool schemas follow the decoder default since rc.113 and leave - // unmodeled result keys open. - expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ - type: "object", - properties: { toolIcon: expect.any(Object) }, - additionalProperties: true, - description: "The preview action completed successfully.", - }); - } -}); diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 62840dd21e6e..4cd06bc4233b 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -4,10 +4,8 @@ import { expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, - PreviewAutomationControlInterruptedError, ProjectId, ProviderInstanceId, - SERVER_BROWSER_AUTOMATION_CLIENT_ID, ThreadId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -103,86 +101,3 @@ it.effect.each([ }), ), ); - -it.effect("close reports why the server browser refused to close a tab", () => - Effect.scoped( - Effect.gen(function* () { - const threadId = ThreadId.make("preview-controls-thread"); - const scope: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("preview-controls-environment"), - requestNamespace: "preview-controls-provider-session", - thread: { - threadId, - providerSessionId: "preview-controls-provider-session", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(["preview"]), - issuedAt: 0, - }; - const manager = yield* Preview.make.pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe( - Layer.provide(NodeServices.layer), - ), - NodeCrypto.layer, - ), - ), - ); - const tab = yield* manager.open({ - threadId, - url: "http://localhost:3000", - runtime: "server", - }); - const layerDependencies = Layer.mergeAll( - Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({ - invoke: () => - Effect.fail( - new PreviewAutomationControlInterruptedError({ - operation: "close", - environmentId: scope.environmentId, - threadId, - providerSessionId: "preview-controls-provider-session", - providerInstanceId: ProviderInstanceId.make("codex"), - clientId: SERVER_BROWSER_AUTOMATION_CLIENT_ID, - connectionId: "server-browser-connection", - requestId: "close-request", - tabId: tab.tabId, - timeoutMs: 1_000, - remoteTag: "PreviewAutomationControlInterruptedError", - remoteMessageLength: 0, - cause: null, - reason: "agentMismatch", - }), - ), - }), - Layer.succeed(Preview.PreviewManager, manager), - Layer.succeed(McpInvocationContext.McpInvocationContext, scope), - McpToolAccessTestkit.liveThreadsLayer, - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed({ - ...DEFAULT_SERVER_SETTINGS, - enableAgentBrowserAccess: true, - }), - }), - ); - const toolkit = yield* PreviewControlsToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), - ), - ); - - const closed = yield* toolkit - .handle("t3_preview_close", { tabId: tab.tabId }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - - expect(closed.at(-1)?.result).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "agentMismatch", - }); - }), - ), -); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 3aeadaa39f56..f6643c74046e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,11 +1,12 @@ +import { ThreadId, type OrchestrationV2ThreadShell } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, - ThreadId, - type OrchestrationV2ThreadShell, type Project as ProjectRecord, + type ModelSelection, + type RuntimeMode, } from "@t3tools/contracts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -339,7 +340,10 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () ); const clientLaunchHarness = (input: { - readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; + readonly runtimeModeCeiling: RuntimeMode; + readonly defaultRuntimeMode?: RuntimeMode; + /** Where Settings saves a project's model; the legacy project field stays null. */ + readonly overrideModel?: ModelSelection; readonly launched: Array; readonly workspaceRoot?: string; readonly checkNewWorktree?: ThreadLaunch.ThreadLaunchService["Service"]["checkNewWorktree"]; @@ -351,7 +355,19 @@ const clientLaunchHarness = (input: { model: "claude-opus", }; const layerDependencies = Layer.mergeAll( - ServerSettings.layerTest(), + ServerSettings.layerTest({ + ...(input.defaultRuntimeMode === undefined + ? {} + : { defaultRuntimeMode: input.defaultRuntimeMode }), + ...(input.overrideModel === undefined + ? {} + : { + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: input.overrideModel }, + }, + }), + }), NodeCrypto.layer, noContextLinks, Layer.succeed(McpInvocationContext.McpInvocationContext, { @@ -393,7 +409,7 @@ const clientLaunchHarness = (input: { ? Option.some({ id, workspaceRoot: input.workspaceRoot ?? "/projects/client-target", - defaultModelSelection: modelSelection, + defaultModelSelection: input.overrideModel === undefined ? modelSelection : null, } as unknown as ProjectRecord) : Option.none(), ), @@ -413,36 +429,89 @@ const clientLaunchHarness = (input: { return { projectId, modelSelection, dependencies: layerDependencies }; }; -it.effect("a client launches at its ceiling with the project's default model", () => - Effect.gen(function* () { - const launched: Array = []; - const { projectId, modelSelection, dependencies } = clientLaunchHarness({ - runtimeModeCeiling: "auto-accept-edits", - launched, - }); - const toolkit = yield* ProjectToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), - ), - ); - const handle = (params: Parameters>[1]) => - toolkit - .handle("t3_thread_launch", params) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); +it.effect( + "a client launches with the project's default model and mode, capped at its ceiling", + () => + Effect.gen(function* () { + const launchWith = Effect.fn("test.launchWith")(function* ( + runtimeModeCeiling: RuntimeMode, + defaultRuntimeMode: RuntimeMode, + params: { + readonly title: string; + readonly message?: string; + readonly runtimeMode?: RuntimeMode; + readonly targeted?: false; + readonly overrideModel?: ModelSelection; + }, + ) { + const { targeted, overrideModel, ...input } = params; + const launched: Array = []; + const { projectId, modelSelection, dependencies } = clientLaunchHarness({ + runtimeModeCeiling, + defaultRuntimeMode, + launched, + ...(overrideModel === undefined ? {} : { overrideModel }), + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe( + Layer.provide(dependencies), + ), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", targeted === false ? input : { ...input, projectId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + return { result: result.at(-1)?.result, launched, projectId, modelSelection }; + }); - const result = yield* handle({ title: "Fix", projectId, message: "Fix the bug" }); - expect(result.at(-1)?.result).toMatchObject({ projectId, modelSelection }); - expect(launched[0]?.runtimeMode).toBe("auto-accept-edits"); - expect(launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); - expect(launched[0]?.startedBy).toEqual({ kind: "agent-access", label: "Claude Code" }); + const belowCeiling = yield* launchWith("full-access", "approval-required", { + title: "Fix", + message: "Fix the bug", + }); + expect(belowCeiling.result).toMatchObject({ + projectId: belowCeiling.projectId, + modelSelection: belowCeiling.modelSelection, + }); + expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); + expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); + expect(belowCeiling.launched[0]?.startedBy).toEqual({ + kind: "agent-access", + label: "Claude Code", + }); - const escalated = yield* handle({ title: "Fix", projectId, runtimeMode: "full-access" }); - expect(escalated.at(-1)?.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" }); + expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); - const untargeted = yield* handle({ title: "Fix" }); - expect(untargeted.at(-1)?.result).toMatchObject({ code: "target_required" }); - expect(launched).toHaveLength(1); - }), + const requested = yield* launchWith("full-access", "approval-required", { + title: "Fix", + runtimeMode: "auto", + }); + expect(requested.launched[0]?.runtimeMode).toBe("auto"); + + const escalated = yield* launchWith("auto-accept-edits", "auto-accept-edits", { + title: "Fix", + runtimeMode: "full-access", + }); + expect(escalated.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); + expect(escalated.launched).toHaveLength(0); + + const untargeted = yield* launchWith("full-access", "full-access", { + title: "Fix", + targeted: false, + }); + expect(untargeted.result).toMatchObject({ code: "target_required" }); + + const overrideModel = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-settings", + }; + const fromSettings = yield* launchWith("full-access", "full-access", { + title: "Fix", + overrideModel, + }); + expect(fromSettings.launched[0]?.modelSelection).toEqual(overrideModel); + }), ); it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () => diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index fd39beddec06..05700357ff3a 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -10,6 +10,9 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpContextLinks from "../../McpContextLinks.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -136,10 +139,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }), ), ); + // A caller outside a thread has nothing to inherit, so it starts with the + // project's effective defaults, as the composer does. Its mode stays + // within the ceiling it was approved with. + const projectDefaults = + caller === undefined + ? yield* ServerSettings.ServerSettingsService.pipe( + Effect.flatMap((settings) => settings.getSettings), + Effect.mapError(unavailable), + Effect.flatMap((settings) => + Effect.map( + readProject, + (project) => resolveProjectSettings(settings, projectId, project).settings, + ), + ), + ) + : undefined; const modelSelection = input.modelSelection ?? caller?.modelSelection ?? - (yield* readProject)?.defaultModelSelection ?? + projectDefaults?.defaultModelSelection ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ @@ -151,13 +170,19 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { text: input.message ?? "", links: input.contextLinks, }); + const startedRuntimeMode = + projectDefaults !== undefined && input.runtimeMode === undefined + ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( + Effect.orElseSucceed(() => runtimeMode), + ) + : runtimeMode; const result = yield* ThreadMessageIntake.launchThread({ commandId, threadId, projectId, title: input.title, modelSelection, - runtimeMode, + runtimeMode: startedRuntimeMode, interactionMode, workspaceStrategy: input.workspaceStrategy ?? { type: "root" }, ...(input.message === undefined && diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 23d48eba21e8..e9ee5a05ad94 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -31,7 +31,7 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import { listThreadPullRequests } from "./handlers.ts"; import * as PullRequestsHandlers from "./handlers.ts"; -import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; +import { PullRequestsToolkit } from "./tools.ts"; const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); @@ -352,23 +352,6 @@ describe("pull request toolkit handlers", () => { }), ); - it.effect("links by repository and number, defaulting the host to the project's", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const result = yield* harness.call("link_pull_request", { - repository: "T3Tools/Other", - number: 7, - }); - expect(result).toEqual({ - host: "github.com", - repository: "t3tools/other", - number: 7, - url: "https://github.com/t3tools/other/pull/7", - alreadyLinked: false, - }); - }), - ); - it.effect("builds the URL in the project host's own shape", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -464,24 +447,6 @@ describe("pull request toolkit handlers", () => { }), ); - it.effect("rejects a target that names neither a URL nor repository and number", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const error = yield* harness - .call("link_pull_request", { repository: "x/y" }) - .pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" }); - const unknown = yield* harness - .call("link_pull_request", { - url: "https://github.com/t3tools/t3code/issues/1?token=private-value", - }) - .pipe(Effect.flip); - expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" }); - expect(unknown.message).not.toContain("private-value"); - expect(yield* Ref.get(harness.commands)).toEqual([]); - }), - ); - it.effect("treats a duplicate link as alreadyLinked rather than an error", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -537,14 +502,6 @@ describe("pull request toolkit handlers", () => { expect(result.pullRequests[0]?.host).toBe("forge.example:3000"); }); - it.effect("fails cleanly when the token's thread no longer exists", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ thread: null }); - const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); - }), - ); - it.effect("lists visible links with host state and derived chain order", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -675,40 +632,6 @@ describe("pull request guide tools", () => { }), ); - it.effect("reports a refused run on its own pull request without failing the rest", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - generate: (input) => - input.number === 1 - ? Effect.fail( - new PullRequestOperationError({ - operation: "generateReviewGuide", - detail: "Review guides are off. Turn them on in Settings → Source Control.", - }), - ) - : Effect.succeed({ - ...NO_GUIDE, - run: { - _tag: "Running" as const, - headSha: "head-1", - startedAt: "2026-10-11T00:00:00.000Z", - }, - started: true, - }), - }); - const result = yield* harness.call("write_pull_request_guides", { - pullRequests: [ - { repository: "t3tools/t3code", number: 1 }, - { repository: "t3tools/t3code", number: 2 }, - ], - }); - expect(result.guides.map((guide) => [guide.number, guide.outcome, guide.detail])).toEqual([ - [1, "failed", "Review guides are off. Turn them on in Settings → Source Control."], - [2, "started", null], - ]); - }), - ); - it.effect("reports an unreadable entry on its own and still writes the rest of the batch", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -726,23 +649,6 @@ describe("pull request guide tools", () => { }), ); - it.effect("reports a run the service joined as running, not as a new one", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - generate: () => - Effect.succeed({ - ...NO_GUIDE, - run: { _tag: "Running", headSha: "head-1", startedAt: "2026-10-11T00:00:00.000Z" }, - started: false, - }), - }); - const result = yield* harness.call("write_pull_request_guides", { - pullRequests: [{ repository: "t3tools/t3code", number: 4 }], - }); - expect(result.guides.map((guide) => guide.outcome)).toEqual(["running"]); - }), - ); - it.effect("finds an Azure DevOps checkout for a pull request named by its full path", () => Effect.gen(function* () { const azure = { @@ -770,22 +676,4 @@ describe("pull request guide tools", () => { ]); }), ); - - it.effect("reads a stored guide and says when a push left it behind", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const result = yield* harness.call("read_pull_request_guide", { - repository: "t3tools/t3code", - number: 3, - }); - expect(result).toMatchObject({ number: 3, guide: null, run: null, stale: false }); - }), - ); -}); - -it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => { - const cause = new Error("database internals"); - const failure = new PullRequestLinkFailedError({ cause }); - expect(failure.message).toBe("Could not link the pull request."); - expect(failure.cause).toBe(cause); }); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 07874613c208..a80c1d0989a6 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -1,195 +1,36 @@ -import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; -import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts"; -import * as DeviceService from "../../../device/DeviceService.ts"; -import * as ServerConfig from "../../../config.ts"; -import { expect, it } from "@effect/vitest"; -import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { HttpBody, HttpClient, HttpRouter } from "effect/http"; - -import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; -import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; -import * as GitHubIssueThreadLinks from "../../../githubIssues/GitHubIssueThreadLinks.ts"; -import * as MessageDrafts from "../../../messageDrafts/MessageDrafts.ts"; -import * as DiscordThreadLinks from "../../../discord/DiscordThreadLinks.ts"; -import * as GranolaThreadLinks from "../../../granola/GranolaThreadLinks.ts"; -import * as LinearApi from "../../../linear/LinearApi.ts"; -import * as NotionApi from "../../../notion/NotionApi.ts"; -import * as NotionThreadLinks from "../../../notion/NotionThreadLinks.ts"; -import * as SlackApi from "../../../slack/SlackApi.ts"; -import * as SlackThreadLinks from "../../../slack/SlackThreadLinks.ts"; -import * as LinearThreadLinks from "../../../linear/LinearThreadLinks.ts"; -import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; -import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; -import * as ProjectService from "../../../project/ProjectService.ts"; -import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts"; -import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; -import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; -import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; -import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; -import * as SecretRequests from "../../../secrets/SecretRequests.ts"; -import * as ServerSettings from "../../../serverSettings.ts"; -import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; -import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; -import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; -import * as ThreadSearch from "../../../orchestration-v2/ThreadSearch.ts"; -import * as PreviewManager from "../../../preview/Manager.ts"; -import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; -import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; -import * as McpHttpServer from "../../McpHttpServer.ts"; -import * as McpContextLinks from "../../McpContextLinks.ts"; -import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; -import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; -import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; - -const layerStubServices = Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({}), - Layer.mock(ProjectionStore.ProjectionStoreV2)({}), - Layer.mock(DeviceService.DeviceService)({}), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(ProviderRegistry.ProviderRegistry)({}), - Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), - ServerSettings.layerTest({}), - Layer.mock(GitWorkflowService.GitWorkflowService)({}), - Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), - Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), - Layer.mock(LinearThreadLinks.LinearThreadLinks)({}), - Layer.mock(LinearApi.LinearApi)({}), - Layer.mock(SlackThreadLinks.SlackThreadLinks)({}), - Layer.mock(SlackApi.SlackApi)({}), - Layer.mock(NotionApi.NotionApi)({}), - Layer.mock(DiscordThreadLinks.DiscordThreadLinks)({}), - Layer.mock(NotionThreadLinks.NotionThreadLinks)({}), - Layer.mock(GranolaThreadLinks.GranolaThreadLinks)({}), - Layer.mock(GitHubIssueThreadLinks.GitHubIssueThreadLinks)({}), - Layer.mock(MessageDrafts.MessageDrafts)({}), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), - Layer.mock(PreviewManager.PreviewManager)({}), - Layer.mock(ServerSecretStore.ServerSecretStore)({}), - Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), - Layer.mock(ThreadSearch.ThreadSearch)({}), - Layer.mock(McpContextLinks.McpContextLinks)({}), - McpProviderSessions.layer, -); - -const ToolsListPayload = Schema.fromJsonString( - Schema.Struct({ - result: Schema.Struct({ - tools: Schema.Array( - Schema.Struct({ - name: Schema.String, - inputSchema: Schema.Struct({ type: Schema.optional(Schema.String) }), - annotations: Schema.optional( - Schema.Struct({ - readOnlyHint: Schema.optional(Schema.Boolean), - destructiveHint: Schema.optional(Schema.Boolean), - openWorldHint: Schema.optional(Schema.Boolean), - }), - ), - }), - ), - }), - }), -); -const decodeToolsListPayload = Schema.decodeUnknownEffect(ToolsListPayload); - -it.effect("production mcp layer lists worktree tools over http", () => - Effect.scoped( - Effect.gen(function* () { - const layerRoutes = McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)); - yield* HttpRouter.serve(layerRoutes, { - disableListenLog: true, - disableLogger: true, - }).pipe( - Layer.provide( - Layer.mock(ServerEnvironment.ServerEnvironment)({ - getEnvironmentId: Effect.succeed("environment-scratch" as never), - }), - ), - Layer.provide(PreviewAutomationBroker.layer), - Layer.provide(PreviewBrowser.layer), - Layer.provide(layerStubServices), - Layer.build, - ); - - const registry = McpSessionRegistry.issueActiveMcpCredential({ - threadId: ThreadId.make("thread-scratch"), - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - }); - const credential = yield* registry; - expect(credential).toBeDefined(); - - const httpClient = yield* HttpClient.HttpClient; - const auth = credential!.config.authorizationHeader; - const initResponse = yield* httpClient.post("/mcp", { - headers: { - accept: "application/json, text/event-stream", - authorization: auth, - }, - body: HttpBody.text( - `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"scratch","version":"1.0.0"}}}`, - "application/json", - ), - }); - expect(initResponse.status).toBe(200); - const sessionId = initResponse.headers["mcp-session-id"]; - - const listResponse = yield* httpClient.post("/mcp", { - headers: { - accept: "application/json, text/event-stream", - authorization: auth, - "mcp-protocol-version": "2025-06-18", - ...(sessionId ? { "mcp-session-id": sessionId } : {}), - }, - body: HttpBody.text( - `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, - "application/json", - ), - }); - const bodyText = yield* listResponse.text; - const payload = yield* decodeToolsListPayload(bodyText.match(/\{.*\}/s)![0]); - const tools = payload.result.tools; - const toolNames = tools.map((tool) => tool.name); - expect(toolNames).toContain("t3_worktree_handoff"); - expect(toolNames).toContain("t3_worktree_status"); - expect(toolNames).toContain("preview_status"); - expect(toolNames).toContain("delegate_task"); - - const handoff = tools.find((tool) => tool.name === "t3_worktree_handoff"); - expect(handoff?.annotations?.readOnlyHint).toBe(false); - expect(handoff?.annotations?.destructiveHint).toBe(true); - expect(handoff?.annotations?.openWorldHint).toBe(true); - const status = tools.find((tool) => tool.name === "t3_worktree_status"); - expect(status?.annotations?.readOnlyHint).toBe(true); - expect(status?.annotations?.destructiveHint).toBe(false); - - // MCP requires every tool input schema to be a top-level object schema. - // A non-object schema (e.g. the anyOf produced by an empty - // Schema.Struct({})) makes clients reject the entire server. - for (const tool of tools) { - expect(tool.inputSchema.type, `inputSchema.type of ${tool.name}`).toBe("object"); - } - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - NodeHttpServer.layerTest, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-worktree-mcp-" }).pipe( - Layer.provide(NodeServices.layer), - ), - NodeServices.layer, - ), - ), - ), -); +import { assert, describe, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import { Tool } from "effect/ai"; + +import { WorktreeToolkit } from "./tools.ts"; + +describe("worktree MCP tools", () => { + it("publishes handoff, status, and list as object-root tools", () => { + const { + t3_worktree_handoff: handoff, + t3_worktree_status: status, + t3_worktree_list: list, + } = WorktreeToolkit.tools; + + assert.strictEqual(Context.get(handoff.annotations, Tool.Readonly), false); + assert.strictEqual(Context.get(handoff.annotations, Tool.Destructive), true); + assert.strictEqual(Context.get(handoff.annotations, Tool.OpenWorld), true); + assert.include(handoff.description ?? "", "continuationPrompt"); + assert.include(handoff.description ?? "", "existing worktree"); + + assert.strictEqual(Context.get(status.annotations, Tool.Readonly), true); + assert.strictEqual(Context.get(status.annotations, Tool.Destructive), false); + + assert.strictEqual(Context.get(list.annotations, Tool.Readonly), true); + assert.strictEqual(Context.get(list.annotations, Tool.Destructive), false); + + for (const [name, tool] of [ + ["t3_worktree_handoff", handoff], + ["t3_worktree_status", status], + ["t3_worktree_list", list], + ] as const) { + const schema = Tool.getJsonSchema(tool) as { readonly type?: unknown }; + assert.strictEqual(schema.type, "object", name); + } + }); +}); diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index e98f824d6088..1fde091c0642 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -251,26 +251,4 @@ describe("McpAppRequests", () => { assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`)); }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), ); - - it.effect("reports why a request cannot run", () => - Effect.gen(function* () { - const run = (layer: ReturnType) => - Effect.gen(function* () { - const requests = yield* McpAppRequests.McpAppRequests; - return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" })); - }).pipe(Effect.provide(layer)); - assert.equal( - yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })), - "not-an-app", - ); - assert.equal( - yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })), - "session-stopped", - ); - assert.equal( - yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })), - "provider-unsupported", - ); - }), - ); }); diff --git a/apps/server/src/observability/DefectReporter.test.ts b/apps/server/src/observability/DefectReporter.test.ts index d70f5af1d096..c749c1a84579 100644 --- a/apps/server/src/observability/DefectReporter.test.ts +++ b/apps/server/src/observability/DefectReporter.test.ts @@ -1,7 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as ErrorReporter from "effect/ErrorReporter"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -81,16 +80,4 @@ describe("DefectReporter", () => { }), ).pipe(Effect.scoped), ); - - it.effect("logs defects, not typed failures or interrupts", () => - withErrorLogs((logs) => - Effect.gen(function* () { - yield* ErrorReporter.report(Cause.fail(new Error("expected"))); - yield* ErrorReporter.report(Cause.interrupt()); - yield* ErrorReporter.report(Cause.die(new Error("bug"))); - - assert.deepEqual((yield* Queue.clear(logs)).map(errorMessage), ["bug"]); - }).pipe(Effect.provide(DefectReporter.layer)), - ), - ); }); diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 0941c8428b1b..767e10bce8fb 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -1,12 +1,4 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Metric from "effect/Metric"; -import * as Tracer from "effect/Tracer"; -import * as TestClock from "effect/testing/TestClock"; import * as EventLoopMonitor from "./EventLoopMonitor.ts"; @@ -27,138 +19,11 @@ const stalled: EventLoopMonitor.EventLoopReadings = { rssBytes: 1536 * 1024 * 1024, heapUsedBytes: 512 * 1024 * 1024, elapsedMs: 30_000, + loadAverage: 219.43, + cpuCount: 18, }; -const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; - -const monitor = ( - samples: Array, - stallLog: EventLoopMonitor.StallLogOptions = { filePath: "\0", maxBytes: 1 }, -) => - Layer.build( - EventLoopMonitor.layerWith( - Effect.succeed(Effect.sync(() => samples.shift() ?? quiet)), - stallLog, - ), - ); describe("EventLoopMonitor", () => { - it.effect("records a warning span only for samples that saw a stall", () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - yield* Effect.gen(function* () { - yield* monitor([stalled, quiet, stalled]); - yield* TestClock.adjust("60 seconds"); - assert.lengthOf(spans, 0); - yield* TestClock.adjust("30 seconds"); - }).pipe(Effect.scoped, Effect.withTracer(tracer)); - - assert.deepStrictEqual( - spans.map((span) => span.name), - ["server.eventLoop.stall"], - ); - const [span] = spans; - assert.deepStrictEqual(Object.fromEntries(span!.attributes), { - delayMaxMs: 4_950, - utilization: 0.18, - cpuUserMs: 310, - cpuSystemMs: 96, - majorPageFaults: 8_412, - minorPageFaults: 20_031, - involuntaryContextSwitches: 57, - rssMb: 1536, - }); - assert.deepStrictEqual( - span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), - [["event loop stalled for 4950 ms", "WARN"]], - ); - }), - ); - - it.effect("exports the server's memory, CPU and event loop from each sample", () => - Effect.gen(function* () { - yield* monitor([quiet, stalled]); - yield* TestClock.adjust("60 seconds"); - - const gauges = Object.fromEntries( - (yield* Metric.snapshot).flatMap((snapshot) => - snapshot.id.startsWith("t3_server_") && snapshot.type === "Gauge" - ? [[snapshot.id, snapshot.state.value]] - : [], - ), - ); - assert.deepStrictEqual(gauges, { - t3_server_resident_bytes: 1536 * 1024 * 1024, - t3_server_heap_used_bytes: 512 * 1024 * 1024, - t3_server_event_loop_utilization: 0.176, - t3_server_event_loop_delay_max: 4_950, - // 406 ms of CPU over 30 s of wall time. - t3_server_cpu_utilization: 406 / 30_000, - }); - }).pipe(Effect.scoped), - ); - - it.effect("keeps each stall as one line in a capped stall log", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-event-loop-" }); - const filePath = `${directory}/event-loop-stalls.ndjson`; - yield* monitor([stalled, stalled, stalled, { ...stalled, delayMaxNs: ms(7_000) }], { - filePath, - maxBytes: 300, - }); - yield* TestClock.adjust("120 seconds"); - - const lines = (yield* fs.readFileString(filePath)).trim().split("\n"); - assert.deepStrictEqual( - lines.map((line) => JSON.parse(line)), - [ - { - time: "1970-01-01T00:02:00.000Z", - delayMaxMs: 6_000, - utilization: 0.18, - cpuUserMs: 310, - cpuSystemMs: 96, - majorPageFaults: 8_412, - minorPageFaults: 20_031, - involuntaryContextSwitches: 57, - rssMb: 1536, - }, - ], - ); - assert.lengthOf((yield* fs.readFileString(`${filePath}.1`)).trim().split("\n"), 1); - assert.isFalse(yield* fs.exists(`${filePath}.2`)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("warns when a stall cannot be written to the stall log", () => { - const warnings: Array = []; - const logger = Logger.make(({ message }) => { - warnings.push(Array.isArray(message) ? message[0] : message); - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const filePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-event-loop-" }); - yield* monitor([stalled, stalled], { filePath, maxBytes: 1024 * 1024 }); - yield* TestClock.adjust("60 seconds"); - assert.deepStrictEqual(warnings, [ - "event loop stalled for 4950 ms", - "event loop stall log write failed", - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - it("ignores delay the loop spent idle, such as a system sleep", () => { // Waking from sleep reads as a long gap, but the loop was idle in poll for it. const asleep: EventLoopMonitor.EventLoopReadings = { diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index a2944b87b749..5e4d55ff59bd 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -1,14 +1,19 @@ -// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. +// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram, and node:os the load average. +import * as NodeOS from "node:os"; import * as NodePerfHooks from "node:perf_hooks"; +import type { ServerEventLoopStall } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as Metrics from "./Metrics.ts"; @@ -21,6 +26,20 @@ const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; const STALL_LOG_MAX_BYTES = 256 * 1024; +// Long enough that users felt it. Shorter stalls stay in the log only. +const NOTICEABLE_STALL_MS = 5000; +// Clients show what they get, so an old stall must not explain a new drop. +const NOTICEABLE_STALL_MAX_AGE_MS = 5 * 60_000; + +export class EventLoopMonitor extends Context.Reference<{ + /** + * The latest stall of 5 s or more, if seen in the last 5 minutes. It samples + * first, so a stall that ended just before a client reconnected is included. + */ + readonly latestNoticeableStall: Effect.Effect; +}>("t3/observability/EventLoopMonitor", { + defaultValue: () => ({ latestNoticeableStall: Effect.succeed(undefined) }), +}) {} /** Delay in ns, active time in ms, CPU in µs. */ export interface EventLoopReadings { @@ -39,6 +58,9 @@ export interface EventLoopReadings { readonly heapUsedBytes: number; /** Wall time the readings cover. */ readonly elapsedMs: number; + /** One-minute load average, undefined on Windows. */ + readonly loadAverage: number | undefined; + readonly cpuCount: number; } // Node skips the first gap after a reset, so a stall right at a sample boundary can be missed. @@ -77,6 +99,9 @@ const makeNodeSampler = Effect.gen(function* () { rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, elapsedMs: nextSampledAt - sampledAt, + // Windows has no load average and reports 0, which a running process never sees elsewhere. + loadAverage: NodeOS.loadavg()[0] || undefined, + cpuCount: NodeOS.availableParallelism(), }; histogram.reset(); elu = nextElu; @@ -116,13 +141,20 @@ export interface StallLogOptions { readonly maxBytes: number; } -export const layerWith = ( +const layerWith = ( makeSampler: Effect.Effect, never, Scope.Scope>, stallLog: StallLogOptions, ) => - Layer.effectDiscard( + Layer.effect( + EventLoopMonitor, Effect.gen(function* () { const sample = yield* makeSampler; + // Clients sample on connect as well as the timer, and the sampler resets shared state. + const sampling = yield* Semaphore.make(1); + const warmedUp = yield* Ref.make(false); + const latestNoticeableStall = yield* Ref.make< + { readonly stall: ServerEventLoopStall; readonly detectedAtMs: number } | undefined + >(undefined); const stallSink = yield* Effect.try( () => new RotatingFileSink({ @@ -143,7 +175,22 @@ export const layerWith = ( yield* recordGauges(readings); const delayMaxMs = stallMs(readings); if (delayMaxMs === undefined) return; - const { utilization, usage, rssBytes } = readings; + const { utilization, usage, rssBytes, loadAverage, cpuCount } = readings; + const now = yield* DateTime.now; + const detectedAt = DateTime.formatIso(now); + if (delayMaxMs >= NOTICEABLE_STALL_MS) { + yield* Ref.set(latestNoticeableStall, { + detectedAtMs: DateTime.toEpochMillis(now), + stall: { + detectedAt, + durationMs: delayMaxMs, + ...(loadAverage === undefined + ? {} + : { loadAverage: Math.round(loadAverage * 10) / 10 }), + cpuCount, + }, + }); + } const attributes = { delayMaxMs, utilization: Math.round(utilization * 100) / 100, @@ -158,8 +205,7 @@ export const layerWith = ( Effect.withSpan("server.eventLoop.stall", { root: true, level: "Warn", attributes }), ); if (stallSink !== undefined) { - const time = DateTime.formatIso(yield* DateTime.now); - yield* encodeStallLine({ time, ...attributes }).pipe( + yield* encodeStallLine({ time: detectedAt, ...attributes }).pipe( Effect.flatMap((line) => Effect.try(() => stallSink.write(`${line}\n`))), Effect.catch(() => Effect.logWarning("event loop stall log write failed", { @@ -168,17 +214,28 @@ export const layerWith = ( ), ); } - }); + }).pipe(sampling.withPermits(1)); const wait = Effect.sleep(SAMPLE_INTERVAL); // The layer builds before the rest of the server, so the first sample covers // startup work such as migrations and projection bootstrap. That can block the // loop for seconds on a large database, so skip it rather than warn at every // launch. yield* wait.pipe( - Effect.andThen(sample), + Effect.andThen(sampling.withPermits(1)(sample)), + Effect.andThen(Ref.set(warmedUp, true)), Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), Effect.forkScoped, ); + + return EventLoopMonitor.of({ + latestNoticeableStall: Effect.gen(function* () { + if (yield* Ref.get(warmedUp)) yield* tick; + const latest = yield* Ref.get(latestNoticeableStall); + if (latest === undefined) return undefined; + const ageMs = DateTime.toEpochMillis(yield* DateTime.now) - latest.detectedAtMs; + return ageMs <= NOTICEABLE_STALL_MAX_AGE_MS ? latest.stall : undefined; + }), + }); }), ); diff --git a/apps/server/src/observability/HeapSnapshot.test.ts b/apps/server/src/observability/HeapSnapshot.test.ts deleted file mode 100644 index 0beb466e5019..000000000000 --- a/apps/server/src/observability/HeapSnapshot.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - tests fake a failed write at the native v8 boundary. -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeV8 from "node:v8"; -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import { vi } from "vite-plus/test"; - -import { writeHeapSnapshot } from "./HeapSnapshot.ts"; - -vi.mock("node:v8", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, writeHeapSnapshot: vi.fn(actual.writeHeapSnapshot) }; -}); - -it.layer(NodeServices.layer)("writeHeapSnapshot", (it) => { - it.effect("removes the partial file when the write fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const logsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-heap-snapshot-test-" }); - let partialPath: string | undefined; - vi.mocked(NodeV8.writeHeapSnapshot).mockImplementationOnce((path) => { - partialPath = path; - if (path) NodeFS.writeFileSync(path, "partial"); - throw new Error("ENOSPC: no space left on device"); - }); - - yield* writeHeapSnapshot(logsDir); - - assert.strictEqual(NodePath.dirname(partialPath ?? ""), logsDir); - assert.deepEqual(yield* fs.readDirectory(logsDir), []); - }), - ); -}); diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts index d015dce3c2a5..e452f4f5ce34 100644 --- a/apps/server/src/observability/HeapSnapshot.ts +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -10,7 +10,7 @@ import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; -export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( +const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( function* (logsDir: string) { const fs = yield* FileSystem.FileSystem; const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); diff --git a/apps/server/src/observability/Metrics.test.ts b/apps/server/src/observability/Metrics.test.ts deleted file mode 100644 index ea1e60012d41..000000000000 --- a/apps/server/src/observability/Metrics.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { ProviderDriverKind } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Metric from "effect/Metric"; -import * as TestClock from "effect/testing/TestClock"; - -import { withMetrics } from "./Metrics.ts"; - -const hasMetricSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.some( - (snapshot) => - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -const findHistogramSnapshot = ( - snapshots: ReadonlyArray, - id: string, - attributes: Readonly>, -) => - snapshots.find( - (snapshot): snapshot is Extract => - snapshot.type === "Histogram" && - snapshot.id === id && - Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), - ); - -describe("withMetrics", () => { - it.effect("supports pipe-style usage", () => - Effect.gen(function* () { - const counter = Metric.counter("with_metrics_pipe_total"); - const timer = Metric.timer("with_metrics_pipe_duration"); - - const result = yield* Effect.succeed("ok").pipe( - withMetrics({ - counter, - timer, - attributes: { - operation: "pipe", - }, - }), - ); - - assert.equal(result, "ok"); - - const snapshots = yield* Metric.snapshot; - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_pipe_total", { - operation: "pipe", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_pipe_duration", { - operation: "pipe", - }), - true, - ); - }), - ); - - it.effect("supports direct invocation", () => - Effect.gen(function* () { - const counter = Metric.counter("with_metrics_direct_total"); - - yield* withMetrics(Effect.fail("boom"), { - counter, - attributes: { - operation: "direct", - }, - }).pipe(Effect.exit); - - const snapshots = yield* Metric.snapshot; - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_direct_total", { - operation: "direct", - outcome: "failure", - }), - true, - ); - }), - ); - - it.effect("evaluates attributes lazily after the wrapped effect runs", () => - Effect.gen(function* () { - const counter = Metric.counter("with_metrics_lazy_total"); - const timer = Metric.timer("with_metrics_lazy_duration"); - let provider = ProviderDriverKind.make("unknown"); - const lazyInittedProvider = ProviderDriverKind.make("codex"); - - yield* Effect.sync(() => { - provider = lazyInittedProvider; - }).pipe( - withMetrics({ - counter, - timer, - attributes: () => ({ - provider, - operation: "lazy", - }), - }), - ); - - const snapshots = yield* Metric.snapshot; - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_lazy_total", { - provider: lazyInittedProvider, - operation: "lazy", - outcome: "success", - }), - true, - ); - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_lazy_duration", { - provider: lazyInittedProvider, - operation: "lazy", - }), - true, - ); - }), - ); - - it.effect("counts interrupted work with an interrupt outcome and its duration", () => - Effect.gen(function* () { - const counter = Metric.counter("with_metrics_interrupt_total"); - const timer = Metric.timer("with_metrics_interrupt_duration"); - const started = yield* Deferred.make(); - - const fiber = yield* Deferred.succeed(started, undefined).pipe( - Effect.andThen(Effect.never), - withMetrics({ counter, timer, attributes: { operation: "interrupt" } }), - Effect.forkChild, - ); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.millis(5)); - yield* Fiber.interrupt(fiber); - - const snapshots = yield* Metric.snapshot; - assert.equal( - hasMetricSnapshot(snapshots, "with_metrics_interrupt_total", { - operation: "interrupt", - outcome: "interrupt", - }), - true, - ); - const duration = findHistogramSnapshot(snapshots, "with_metrics_interrupt_duration", { - operation: "interrupt", - }); - assert.equal(duration?.state.count, 1); - assert.equal(duration?.state.sum, 5); - }), - ); - - it.effect("measures durations on the monotonic clock, not the wall clock", () => - Effect.gen(function* () { - const timer = Metric.timer("with_metrics_monotonic_duration"); - const started = yield* Deferred.make(); - const finish = yield* Deferred.make(); - - const fiber = yield* Deferred.succeed(started, undefined).pipe( - Effect.andThen(Deferred.await(finish)), - withMetrics({ timer, attributes: { operation: "monotonic" } }), - Effect.forkChild, - ); - yield* Deferred.await(started); - yield* TestClock.adjust(Duration.millis(10)); - yield* TestClock.setTime(0); - yield* Deferred.succeed(finish, undefined); - yield* Fiber.join(fiber); - - const snapshots = yield* Metric.snapshot; - const duration = findHistogramSnapshot(snapshots, "with_metrics_monotonic_duration", { - operation: "monotonic", - }); - assert.equal(duration?.state.count, 1); - assert.equal(duration?.state.sum, 10); - }), - ); - - it.effect("records timer durations from nanosecond clock readings", () => - Effect.gen(function* () { - const duration = Duration.nanos(1_500_000n); - const timer = Metric.timer("with_metrics_nanos_duration"); - - yield* Effect.gen(function* () { - const fiber = yield* Effect.sleep(duration).pipe( - withMetrics({ - timer, - attributes: { - operation: "nanos", - }, - }), - Effect.forkChild, - ); - - yield* Effect.yieldNow; - yield* TestClock.adjust(duration); - yield* Fiber.join(fiber); - }).pipe(Effect.provide(TestClock.layer())); - - const snapshots = yield* Metric.snapshot; - const snapshot = findHistogramSnapshot(snapshots, "with_metrics_nanos_duration", { - operation: "nanos", - }); - - assert.equal(snapshot?.state.count, 1); - assert.equal(snapshot?.state.sum, 1.5); - }), - ); -}); diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts index 47d4ad76a6fe..ad9cf66004e0 100644 --- a/apps/server/src/observability/OtlpEndpointCheck.test.ts +++ b/apps/server/src/observability/OtlpEndpointCheck.test.ts @@ -71,22 +71,6 @@ const check = (input: Parameters { - it.effect("posts an empty export for the signal with the T3 headers", () => { - const { requests, layer } = checkWith(200, { - otlpMetricsUrl: "http://collector:4318/v1/metrics", - otlpMetricsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } }, - }); - return Effect.gen(function* () { - const result = yield* check({ signal: "metrics", url: "http://collector:4318/v1/metrics" }); - - assert.strictEqual(result._tag, "Accepted"); - assert.lengthOf(requests, 1); - assert.strictEqual(requests[0]?.url, "http://collector:4318/v1/metrics"); - assert.strictEqual(requests[0]?.headers.authorization, "Bearer t3"); - assert.strictEqual(requests[0]?.body, `{"resourceMetrics":[]}`); - }).pipe(Effect.provide(layer)); - }); - it.effect("keeps T3 credentials paired with each running signal endpoint", () => { const signalExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } }; const { requests, layer } = checkWith(200, { @@ -130,15 +114,6 @@ describe("OtlpEndpointCheck", () => { }).pipe(Effect.provide(layer)); }); - it.effect("releases the request once the status is known", () => { - const { requests, layer } = checkWith(200); - return Effect.gen(function* () { - yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" }); - - assert.isTrue(requests[0]?.signal.aborted); - }).pipe(Effect.provide(layer)); - }); - it.effect("keeps endpoint URLs and credentials out of traces", () => { const url = "https://collector.example.com/v1/logs?api_key=query-secret"; const { requests, layer } = checkWith(200, { @@ -204,24 +179,6 @@ describe("OtlpEndpointCheck", () => { ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("reports the status a receiver rejects the export with", () => { - const { layer } = checkWith(401); - return Effect.gen(function* () { - const result = yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" }); - - assert.deepStrictEqual(result, { _tag: "Rejected", status: 401 }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reports an endpoint that cannot be reached", () => { - const { layer } = checkWith(null); - return Effect.gen(function* () { - const result = yield* check({ signal: "logs", url: "http://localhost:1/v1/logs" }); - - assert.deepStrictEqual(result, { _tag: "Unreachable", timedOut: false }); - }).pipe(Effect.provide(layer)); - }); - it.effect("keeps OTEL endpoint credentials off other endpoints", () => { const otelUrl = "https://otel.example.com/v1/traces"; const otelExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } }; diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts deleted file mode 100644 index 84827fa18045..000000000000 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { - AuthEnvironmentMaintainScope, - AuthOrchestrationReadScope, - type AuthEnvironmentScope, - ScheduledTaskError, - ScheduledTaskId, - WS_METHODS, - WsRpcGroup, -} from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Metric from "effect/Metric"; -import * as Option from "effect/Option"; -import * as Stream from "effect/Stream"; -import * as Tracer from "effect/Tracer"; -import * as RpcTest from "effect/rpc/RpcTest"; -import * as TestClock from "effect/testing/TestClock"; - -import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; -import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; -import { RpcInstrumentation, rpcInstrumentationLayer } from "./RpcInstrumentation.ts"; - -type WsRpcMethod = keyof typeof RPC_REQUIRED_SCOPES; - -const groupOf = >(...tags: Tags) => - WsRpcGroup.omit( - ...[...WsRpcGroup.requests.keys()].filter( - (tag): tag is Exclude => - !(tags as ReadonlyArray).includes(tag), - ), - ).middleware(RpcInstrumentation); - -const connectionMiddleware = (scopes: ReadonlyArray) => - Layer.merge(RpcAuthorization.layer(scopes), rpcInstrumentationLayer); -const readOnlyConnection = connectionMiddleware([AuthOrchestrationReadScope]); -const taskId = ScheduledTaskId.make("scheduled-task:instrumented"); -const rpcSpanDefaults = { "rpc.transport": "websocket", "rpc.system": "effect-rpc" }; - -const withTelemetry = ( - test: (ended: ReadonlyArray) => Effect.Effect, -) => { - const ended: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - ended.push(span); - }; - return span; - }, - }); - return test(ended).pipe( - Effect.scoped, - Effect.withTracer(tracer), - Effect.provideService(Metric.MetricRegistry, new Map()), - ); -}; - -const rpcSpans = (ended: ReadonlyArray) => - ended.filter((span) => span.name.startsWith("ws.rpc.")); - -// RpcTest keeps Effect's own RpcServer/RpcClient spans, which ws.ts turns off with -// `disableTracing: true`. -const appSpans = (ended: ReadonlyArray) => - ended.filter((span) => span.attributes.get("rpc.system.name") !== "effect_rpc"); - -const exitTag = (span: Tracer.NativeSpan | undefined) => - span?.status._tag === "Ended" ? span.status.exit._tag : undefined; - -const parentSpanId = (span: Tracer.NativeSpan | undefined) => - span?.parent._tag === "Some" ? span.parent.value.spanId : undefined; - -const requestCount = ( - snapshots: ReadonlyArray, - method: string, - outcome: string, -) => - snapshots.find( - (snapshot): snapshot is Extract => - snapshot.type === "Counter" && - snapshot.id === "t3_rpc_requests_total" && - snapshot.attributes?.["method"] === method && - snapshot.attributes?.["outcome"] === outcome, - )?.state; - -const requestDuration = (snapshots: ReadonlyArray, method: string) => - snapshots.find( - (snapshot): snapshot is Extract => - snapshot.type === "Histogram" && - snapshot.id === "t3_rpc_request_duration" && - snapshot.attributes?.["method"] === method, - )?.state; - -describe("WS RPC instrumentation middleware", () => { - it.effect("records one span and request metric per call, including rejected calls", () => - withTelemetry((ended) => - Effect.gen(function* () { - const group = groupOf( - WS_METHODS.serverProbe, - WS_METHODS.scheduledTasksList, - WS_METHODS.serverRetryResourceTelemetry, - WS_METHODS.pullRequestsSubscribeRefreshes, - WS_METHODS.scheduledTasksSubscribe, - WS_METHODS.serverGetSettings, - ); - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.serverProbe, () => - Effect.succeed({}).pipe(Effect.withSpan("serverProbe.child")), - ), - group.toLayerHandler(WS_METHODS.scheduledTasksList, () => - Effect.annotateCurrentSpan({ "scheduled_task.id": taskId }).pipe( - Effect.andThen(new ScheduledTaskError({ message: "List failed." })), - ), - ), - group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () => - Effect.die("authorization let a rejected call through"), - ), - group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => - Stream.make(1, 2), - ), - group.toLayerHandler(WS_METHODS.scheduledTasksSubscribe, () => - Stream.concat( - Stream.make({ tasks: [] }), - Stream.fail(new ScheduledTaskError({ message: "Subscription failed." })), - ), - ), - group.toLayerHandler(WS_METHODS.serverGetSettings, () => Effect.die("broken")), - readOnlyConnection, - ), - ), - ); - - assert.deepStrictEqual(yield* client[WS_METHODS.serverProbe]({}), {}); - const listError = yield* client[WS_METHODS.scheduledTasksList]({}).pipe(Effect.flip); - assert.equal(listError._tag, "ScheduledTaskError"); - const rejection = yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe( - Effect.flip, - ); - assert.equal(rejection._tag, "EnvironmentAuthorizationError"); - const refreshes = yield* Stream.runCollect( - client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), - ); - assert.deepStrictEqual(Array.from(refreshes), [1, 2]); - const subscribeError = yield* Stream.runDrain( - client[WS_METHODS.scheduledTasksSubscribe]({}), - ).pipe(Effect.flip); - assert.equal(subscribeError._tag, "ScheduledTaskError"); - const settingsExit = yield* Effect.exit(client[WS_METHODS.serverGetSettings]({})); - assert.isTrue(Exit.hasDies(settingsExit)); - - assert.deepStrictEqual( - rpcSpans(ended).map((span) => [span.name, Object.fromEntries(span.attributes)]), - [ - [ - "ws.rpc.server.probe", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.serverProbe, - "rpc.aggregate": "server", - }, - ], - [ - "ws.rpc.scheduledTasks.list", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.scheduledTasksList, - "rpc.aggregate": "scheduledTasks", - "scheduled_task.id": taskId, - }, - ], - [ - "ws.rpc.server.retryResourceTelemetry", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.serverRetryResourceTelemetry, - "rpc.aggregate": "server", - }, - ], - [ - "ws.rpc.pullRequests.subscribeRefreshes", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.pullRequestsSubscribeRefreshes, - "rpc.aggregate": "pull-requests", - }, - ], - [ - "ws.rpc.scheduledTasks.subscribe", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.scheduledTasksSubscribe, - "rpc.aggregate": "scheduledTasks", - }, - ], - [ - "ws.rpc.server.getSettings", - { - ...rpcSpanDefaults, - "rpc.method": WS_METHODS.serverGetSettings, - "rpc.aggregate": "server", - }, - ], - ], - ); - assert.deepStrictEqual(rpcSpans(ended).map(exitTag), [ - "Success", - "Failure", - "Failure", - "Success", - "Failure", - "Failure", - ]); - const probeSpan = rpcSpans(ended)[0]; - const child = ended.find((span) => span.name === "serverProbe.child"); - assert.equal(parentSpanId(child), probeSpan?.spanId); - - const snapshots = yield* Metric.snapshot; - assert.deepStrictEqual( - [ - requestCount(snapshots, WS_METHODS.serverProbe, "success"), - requestCount(snapshots, WS_METHODS.scheduledTasksList, "failure"), - requestCount(snapshots, WS_METHODS.serverRetryResourceTelemetry, "failure"), - requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "success"), - requestCount(snapshots, WS_METHODS.scheduledTasksSubscribe, "failure"), - requestCount(snapshots, WS_METHODS.serverGetSettings, "failure"), - ].map((state) => state?.count), - [1, 1, 1, 1, 1, 1], - ); - for (const method of group.requests.keys()) { - assert.equal(requestDuration(snapshots, method)?.count, 1); - } - }), - ), - ); - - it.effect("keeps a stream's span and duration open until the subscription is interrupted", () => - withTelemetry((ended) => - Effect.gen(function* () { - const waiting = yield* Deferred.make(); - const group = groupOf(WS_METHODS.pullRequestsSubscribeRefreshes); - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.pullRequestsSubscribeRefreshes, () => - Stream.concat( - Stream.make(1), - Stream.fromEffect( - Deferred.succeed(waiting, undefined).pipe( - Effect.andThen(Effect.never), - Effect.withSpan("refreshes.wait"), - ), - ), - ), - ), - readOnlyConnection, - ), - ), - ); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* TestClock.setTime(0); - const consumer = yield* Stream.runDrain( - client[WS_METHODS.pullRequestsSubscribeRefreshes]({}), - ).pipe(Effect.forkChild); - yield* Deferred.await(waiting); - yield* TestClock.adjust(Duration.millis(250)); - yield* TestClock.setTime(0); - assert.deepStrictEqual(appSpans(ended), []); - - yield* Fiber.interrupt(consumer); - - const [rpcSpan] = rpcSpans(ended); - assert.equal(rpcSpan?.name, "ws.rpc.pullRequests.subscribeRefreshes"); - assert.equal(exitTag(rpcSpan), "Failure"); - const waitSpan = ended.find((span) => span.name === "refreshes.wait"); - assert.equal(parentSpanId(waitSpan), rpcSpan?.spanId); - - const snapshots = yield* Metric.snapshot; - assert.equal( - requestCount(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes, "interrupt")?.count, - 1, - ); - const duration = requestDuration(snapshots, WS_METHODS.pullRequestsSubscribeRefreshes); - assert.equal(duration?.count, 1); - assert.equal(duration?.sum, 250); - }), - ), - ); - - it.effect("records metrics but no spans for methods with tracing disabled", () => - withTelemetry((ended) => - Effect.gen(function* () { - const group = groupOf(WS_METHODS.serverSignalProcess); - const client = yield* RpcTest.makeClient(group).pipe( - Effect.provide( - Layer.mergeAll( - group.toLayerHandler(WS_METHODS.serverSignalProcess, (input) => - Effect.succeed({ - pid: input.pid, - signal: input.signal, - signaled: true, - message: Option.none(), - }).pipe(Effect.withSpan("signalProcess.child")), - ), - connectionMiddleware([AuthOrchestrationReadScope, AuthEnvironmentMaintainScope]), - ), - ), - ); - - const input = { pid: 4242, startTimeMs: 0, signal: "SIGINT" } as const; - assert.equal((yield* client[WS_METHODS.serverSignalProcess](input)).signaled, true); - - assert.deepStrictEqual(appSpans(ended), []); - const snapshots = yield* Metric.snapshot; - assert.equal(requestCount(snapshots, WS_METHODS.serverSignalProcess, "success")?.count, 1); - }), - ), - ); -}); diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts deleted file mode 100644 index 170c3802e5b3..000000000000 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { - EnvironmentId, - CommandId, - type ModelSelection, - MessageId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import * as Console from "effect/Console"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as ResetCreditCoordinator from "../provider/resetCreditCoordinator.ts"; -import { FetchHttpClient } from "effect/http"; -import { describe } from "vite-plus/test"; - -import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; -import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import * as HostPowerMonitor from "../background/HostPowerMonitor.ts"; -import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts"; -import * as CodexInstallation from "../provider/CodexInstallation.ts"; -import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; -import * as ModelManifest from "../provider/ModelManifest.ts"; -import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts"; -import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; -import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; -import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; -import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; -import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; -import * as ServerSettings from "../serverSettings.ts"; -import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.testkit.ts"; -import * as RuntimeLayer from "./runtimeLayer.ts"; -import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; - -const layerPlatformTest = Layer.merge( - NodeServices.layer, - Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ - resolveLink: () => Effect.die("unused title link"), - }), -); - -const runAntigravityFixture = process.env.T3_ACP_ANTIGRAVITY_LIVE === "1"; -const liveAgentId = runAntigravityFixture - ? "antigravity-acp" - : process.env.T3_ACP_REGISTRY_LIVE_AGENT_ID?.trim() || "devin"; -const liveCommandPath = process.env.T3_ACP_REGISTRY_LIVE_COMMAND?.trim(); -const liveInstanceId = ProviderInstanceId.make("acpRegistry_live"); -const liveModelSelection = { - instanceId: liveInstanceId, - model: "default", -} satisfies ModelSelection; - -const layerServerConfig = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-acp-registry-v2-live-", -}); - -const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( - Layer.provide(VcsProcess.layer), - Layer.provide(layerServerConfig), - Layer.provide(layerPlatformTest), -); - -const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); - -const layerServerSettings = ServerSettings.layerTest({ - providerInstances: { - [liveInstanceId]: { - driver: ProviderDriverKind.make("acpRegistry"), - displayName: `ACP Registry: ${liveAgentId}`, - enabled: true, - config: { - agentId: liveAgentId, - ...(liveCommandPath ? { commandPath: liveCommandPath } : {}), - }, - }, - }, -}); -const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( - Layer.provide(Layer.effect(HostPowerMonitor.HostPowerMonitor, HostPowerMonitor.make())), - Layer.provide(layerServerSettings), -); -const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerServerConfig.pipe(Layer.provide(layerPlatformTest)), - layerServerSettings, - ServerSecretStore.layer.pipe( - Layer.provide(layerServerConfig), - Layer.provide(layerPlatformTest), - ), - NodeServices.layer, - FetchHttpClient.layer, - OpenCodeRuntime.layer.pipe( - Layer.provide(OpenCodeServerLedger.layerTest), - Layer.provide(layerPlatformTest), - ), - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ProviderLatestVersions.layer, - ModelManifest.layerTest, - AntigravityInstallation.AntigravityInstallation.layer.pipe( - Layer.provide(layerServerConfig.pipe(Layer.provide(layerPlatformTest))), - Layer.provide(FetchHttpClient.layer), - Layer.provide(layerPlatformTest), - ), - Layer.mock(CodexInstallation.CodexInstallation)({ - managedDirectory: "unused-managed-installation", - }), - Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { - getEnvironmentId: Effect.succeed( - EnvironmentId.make("00000000-0000-4000-8000-000000000001"), - ), - }), - ), - ), -); - -const layerLive = RuntimeLayer.layer.pipe( - Layer.provide(McpSessionRegistryTestkit.layer), - Layer.provide(SqlitePersistence.layerMemory), - Layer.provide(layerCheckpointStore), - Layer.provide(layerServerConfig), - Layer.provide(layerServerSettings), - Layer.provide(layerProviderInstanceRegistry), - Layer.provide(ResetCreditCoordinator.layer), - Layer.provide(layerBackgroundPolicy), - Layer.provide(ProviderTurnStartServiceTestkit.layer), - Layer.provide(layerPlatformTest), - Layer.provide(McpProviderSessions.layer), -); - -const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* ( - threadId: ThreadId, - expectedRunCount: number, -) { - const orchestrator = yield* Orchestrator.OrchestratorV2; - for (let attempt = 0; attempt < 900; attempt += 1) { - const projection = yield* orchestrator.getThreadProjection(threadId); - if ( - projection.runs.length >= expectedRunCount && - projection.runs.every( - (run) => !["queued", "starting", "running", "waiting"].includes(run.status), - ) - ) { - return projection; - } - yield* Effect.sleep("500 millis"); - } - return yield* Effect.die(new Error(`Timed out waiting for ACP Registry thread ${threadId}.`)); -}); - -describe.runIf(runAntigravityFixture || process.env.T3_ACP_REGISTRY_LIVE_ORCHESTRATOR === "1")( - "ACP Registry V2 live orchestrator", - () => { - it.live( - `runs and resumes ${runAntigravityFixture ? "Google Antigravity" : "a real registry agent"} through the production V2 harness`, - () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const projectId = ProjectId.make("project:acp-registry-live"); - const threadId = ThreadId.make("thread:acp-registry-live"); - const marker = "ACP_REGISTRY_LIVE_7H3Q"; - - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:acp-registry-live:create"), - threadId, - projectId, - title: `ACP Registry live: ${liveAgentId}`, - modelSelection: liveModelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - }); - yield* Console.log( - `ACP Registry thread created for '${liveAgentId}'; dispatching first prompt.`, - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:acp-registry-live:first"), - threadId, - messageId: MessageId.make("message:acp-registry-live:first"), - text: `Remember this opaque marker. Respond with exactly: ${marker}`, - attachments: [], - modelSelection: liveModelSelection, - dispatchMode: { type: "start_immediately" }, - }); - const firstProjection = yield* waitForIdle(threadId, 1); - const firstAssistant = firstProjection.messages.findLast( - (message) => message.role === "assistant", - )?.text; - - assert.deepEqual( - firstProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [[liveInstanceId, "completed"]], - ); - assert.include(firstAssistant ?? "", marker); - - yield* Console.log("First ACP turn completed; dispatching continuation prompt."); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:acp-registry-live:second"), - threadId, - messageId: MessageId.make("message:acp-registry-live:second"), - text: "Return the opaque marker from the previous turn. Respond with only the marker.", - attachments: [], - modelSelection: liveModelSelection, - dispatchMode: { type: "start_immediately" }, - }); - const finalProjection = yield* waitForIdle(threadId, 2); - const finalAssistant = finalProjection.messages.findLast( - (message) => message.role === "assistant", - )?.text; - - assert.deepEqual( - finalProjection.runs.map((run) => [run.providerInstanceId, run.status]), - [ - [liveInstanceId, "completed"], - [liveInstanceId, "completed"], - ], - ); - assert.include(finalAssistant ?? "", marker); - assert.deepEqual(finalProjection.providerSessions.length, 2); - assert.isAtLeast(finalProjection.providerThreads.length, 1); - assert.deepEqual( - finalProjection.providerTurns.map((turn) => turn.status), - ["completed", "completed"], - ); - }).pipe(Effect.provide(layerLive), Effect.scoped), - 480_000, - ); - }, -); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b5b08ed92eb2..685d901e170c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -10,8 +10,6 @@ import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { AcpRegistryCatalog } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import { decodeAcpReplayTranscript, makeAcpReplayRuntime } from "./AcpAdapterV2.testkit.ts"; // @effect-diagnostics nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -23,7 +21,6 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderSessionId, - ProviderThreadId, ThreadId, type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, @@ -46,10 +43,8 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; @@ -67,8 +62,8 @@ import { acpProviderOptionDescriptors, } from "@t3tools/provider-acp/server/sessionConfig"; import { - extractXAiAcpSubagentEndNotice, extractXAiAcpSubagentUpdate, + makeGrokAdapterV2, makeXAiPromptCompletionRuntime, normalizeXAiAcpToolCallState, registerXAiBackgroundTaskTracking, @@ -79,17 +74,10 @@ import { makeTurnInput } from "./AcpAdapterV2.testkit.ts"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import { AcpProviderCapabilitiesV2, - acpProviderItemNativeId, acpScopedNativeId, - acpCarryoverTerminalShouldClearContinuation, acpPostSettleContinuationOfferEvidence, - acpIsAppOwnedWakeTurn, - acpPostSettleMonitorPromptShouldSuppress, acpPostSettleWakeEvidence, - acpPostSettleWakeShouldBuffer, - acpProjectedCommandExitCode, acpToolCallDiffPatch, - acpTurnStartShouldPreserveContinuation, makeAcpAdapterV2, type AcpAdapterV2ExtensionContext, type AcpAdapterV2Flavor, @@ -99,7 +87,7 @@ import { import { appendUserInputAttachmentPaths } from "../../provider/userInputAttachments.ts"; -import { makeGrokAdapterV2, makeGrokAcpAdapterFlavor } from "@t3tools/provider-grok/testing"; +import { makeGrokAcpAdapterFlavor } from "@t3tools/provider-grok/testing"; import { acpRegistryPromptFailure, registerMistralVibeAcpExtensions, @@ -119,24 +107,6 @@ const layerTest = Layer.mergeAll( const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); -describe("acpProjectedCommandExitCode", () => { - const successOutput = { type: "Bash", exit_code: 0 }; - const failedOutput = { type: "Bash", exit_code: 1 }; - - it("omits exit codes for non-terminal and interrupted tool statuses", () => { - assert.equal(acpProjectedCommandExitCode("pending", successOutput), undefined); - assert.equal(acpProjectedCommandExitCode("running", successOutput), undefined); - assert.equal(acpProjectedCommandExitCode("interrupted", successOutput), undefined); - }); - - it("projects real exit codes only for completed and failed tools", () => { - assert.equal(acpProjectedCommandExitCode("completed", successOutput), 0); - assert.equal(acpProjectedCommandExitCode("completed", failedOutput), 1); - assert.equal(acpProjectedCommandExitCode("failed", failedOutput), 1); - assert.equal(acpProjectedCommandExitCode("completed", {}), undefined); - }); -}); - describe("acpToolCallDiffPatch", () => { it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => { assert.equal( @@ -162,221 +132,8 @@ describe("acpToolCallDiffPatch", () => { ].join("\n"), ); }); - - it("keeps the ACP v2 patch text as sent", () => { - const text = "diff --git a/repo/a.ts b/repo/a.ts\n"; - assert.equal( - acpToolCallDiffPatch([ - { - type: "diff", - changes: [{ operation: "modify", path: "/repo/a.ts" }], - patch: { format: "git_patch", text }, - }, - ]), - text, - ); - }); - - it("drops the patch for a rewrite too large to diff cheaply", () => { - const lines = (prefix: string) => - Array.from({ length: 2_000 }, (_, index) => `${prefix} ${index}`).join("\n"); - assert.isUndefined( - acpToolCallDiffPatch([ - { type: "diff", path: "/repo/big.ts", oldText: lines("old"), newText: lines("new") }, - ]), - ); - }); -}); - -describe("ACP continuation ownership", () => { - it("preserves a continuation offered during non-buffered carryover handling", () => { - assert.isFalse( - acpCarryoverTerminalShouldClearContinuation({ - continuationOffered: true, - wakeBufferLength: 0, - }), - ); - assert.isFalse( - acpCarryoverTerminalShouldClearContinuation({ - continuationOffered: false, - wakeBufferLength: 1, - }), - ); - assert.isTrue( - acpCarryoverTerminalShouldClearContinuation({ - continuationOffered: false, - wakeBufferLength: 0, - }), - ); - }); - - it("preserves only user-raced offers that still own buffered wake traffic", () => { - assert.isTrue( - acpTurnStartShouldPreserveContinuation({ - continuationRequested: true, - isContinuationTurn: false, - wakeBufferLength: 1, - }), - ); - assert.isFalse( - acpTurnStartShouldPreserveContinuation({ - continuationRequested: true, - isContinuationTurn: false, - wakeBufferLength: 0, - }), - ); - assert.isFalse( - acpTurnStartShouldPreserveContinuation({ - continuationRequested: true, - isContinuationTurn: true, - wakeBufferLength: 1, - }), - ); - }); }); -const taskkillPlatformError = (method: string) => - PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method }); - -function makeTaskkillSpawner(input: { - readonly exitCode?: number; - readonly exitFailure?: boolean; - readonly output?: string; - readonly outputFailure?: boolean; - readonly spawnFailure?: boolean; - readonly commands?: Array<{ readonly command: string; readonly args: ReadonlyArray }>; -}) { - return ChildProcessSpawner.make((command) => { - const value = command as unknown as { - readonly command: string; - readonly args: ReadonlyArray; - }; - input.commands?.push({ command: value.command, args: value.args }); - if (input.spawnFailure === true) return Effect.fail(taskkillPlatformError("spawn")); - const output = input.outputFailure - ? Stream.fail(taskkillPlatformError("output")) - : Stream.encodeText(Stream.make(input.output ?? "")); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1234), - exitCode: input.exitFailure - ? Effect.fail(taskkillPlatformError("exitCode")) - : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: output, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); -} - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -const waitForProcesses = (pids: ReadonlyArray) => - Effect.gen(function* () { - while (!pids.every(processExists)) { - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeoutOption("2 seconds")); - -const waitForProcessesToExit = (pids: ReadonlyArray) => - Effect.gen(function* () { - while (pids.some(processExists)) { - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeoutOption("2 seconds")); - -function linuxProcessStart(pid: number): string | undefined { - try { - const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); - const commandEnd = stat.lastIndexOf(")"); - return commandEnd < 0 - ? undefined - : stat - .slice(commandEnd + 2) - .trim() - .split(/\s+/)[19]; - } catch { - return undefined; - } -} - -function cleanupPublishedDetachedFixture(path: string): void { - let published: Array; - try { - published = NodeFS.readFileSync(path, "utf8") - .trim() - .split(/\s+/) - .map(Number) - .filter((pid) => Number.isSafeInteger(pid) && pid > 1); - } catch { - return; - } - const roots = published.filter((pid) => { - try { - return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(path); - } catch { - return false; - } - }); - const owned = new Map(); - const pending = [...roots]; - while (pending.length > 0) { - const pid = pending.shift(); - if (pid === undefined || owned.has(pid)) continue; - const start = linuxProcessStart(pid); - if (start === undefined) continue; - owned.set(pid, start); - try { - pending.push( - ...NodeFS.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8") - .trim() - .split(/\s+/) - .filter(Boolean) - .map(Number), - ); - } catch {} - } - for (const [pid, start] of [...owned.entries()].toReversed()) { - if (linuxProcessStart(pid) !== start) continue; - try { - process.kill(pid, "SIGKILL"); - } catch {} - } -} - -const waitForPublishedProcessIds = ( - fileSystem: FileSystem.FileSystem, - path: string, - count: number, -) => - Effect.gen(function* () { - while (true) { - const ids = (yield* fileSystem.readFileString(path)) - .trim() - .split(/\s+/) - .filter(Boolean) - .map(Number); - if (ids.length === count && ids.every((pid) => Number.isSafeInteger(pid) && pid > 1)) { - return ids; - } - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeoutOption("2 seconds")); - function makeMockRuntime(input: { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly mockAgentPath: string; @@ -672,6 +429,7 @@ describe("AcpAdapterV2", () => { ], })); + // regression: #20417 Droid AskUser answers were sent as a cancel it.live.each([ { scenario: "preserves selected IDs and encodes custom answers", @@ -716,19 +474,6 @@ describe("AcpAdapterV2", () => { answers: { name: "Ada" }, result: { action: "accept", content: { name: "Ada" } }, }, - { - scenario: "falls back when the companion field is missing", - schema: { - type: "object", - properties: { question_1: droidChoice }, - required: ["question_1"], - }, - questions: [ - { id: "question_1", header: "Format", question: "Which reply format?", options: [] }, - ], - answers: { question_1: "Markdown" }, - result: { action: "accept", content: { question_1: "Markdown" } }, - }, ])("Droid elicitation $scenario", ({ schema, questions, answers, result, withAttachments }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -904,7 +649,7 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live.each(["failed", "recovered", "completed", "cancelled"] as const)( + it.live.each(["failed", "recovered", "cancelled"] as const)( "projects Mistral retry notices and their %s outcome", (outcome) => Effect.gen(function* () { @@ -991,79 +736,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it("preserves legacy ids and scopes v2 ids by provider instance", () => { - const instanceId = ProviderInstanceId.make("acp-identity-test"); - assert.equal( - acpProviderItemNativeId({ instanceId, itemIdentityVersion: undefined, nativeId: "item-1" }), - "item-1", - ); - assert.equal( - acpProviderItemNativeId({ instanceId, itemIdentityVersion: 2, nativeId: "item-1" }), - acpScopedNativeId(instanceId, "item-1"), - ); - }); - - it.effect("starts the MCP bridge directly from the self-contained runtime", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation().pipe( - Effect.provideService(HostProcess.IsExecutable, true), - ); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - - const instanceId = ProviderInstanceId.make("acp-test-self-contained-mcp-bridge"); - const threadId = ThreadId.make("thread-acp-self-contained-mcp-bridge"); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("environment-acp-self-contained-mcp-bridge"), - threadId, - providerSessionId: "mcp-session-acp-self-contained-mcp-bridge", - providerInstanceId: instanceId, - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer self-contained-mcp-bridge-token", - browserToolsAvailable: false, - }); - - let runtimeInput: AcpAdapterV2RuntimeInput | undefined; - const makeRuntime = makeMockRuntime({ childProcessSpawner, mockAgentPath }); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: (input) => - Effect.sync(() => { - runtimeInput = input; - }).pipe(Effect.andThen(makeRuntime(input))), - }, - selfInvocation, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-self-contained-mcp-bridge"), - modelSelection, - runtimePolicy, - }); - - const mcpServer = runtimeInput?.mcpServers[0]; - if (mcpServer === undefined || !("command" in mcpServer)) { - return yield* Effect.die("ACP runtime must receive the t3-code stdio MCP server"); - } - assert.equal(mcpServer.command, process.execPath); - assert.deepEqual(mcpServer.args, ["acp-mcp-bridge"]); - assert.equal(runtimeInput?.processEnvironment?.T3_ACP_MCP_NODE, process.execPath); - assert.equal(runtimeInput?.processEnvironment?.T3_ACP_MCP_ENTRYPOINT, undefined); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.live("refreshes ACP prompt instructions when the interaction mode changes", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1326,7 +998,8 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect.each(["agent_message_chunk", "agent_thought_chunk"] as const)( + // regression: #21499 turns blocked only on T3 thread waits must show as waiting + it.effect.each(["agent_message_chunk"] as const)( "keeps named ACP %s open while deriving thread wait execution state", (sessionUpdate) => Effect.gen(function* () { @@ -2431,144 +2104,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("coalesces streamed ACP text into bounded snapshots without delaying its start", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const protocolEvents = yield* Queue.unbounded(); - const instanceId = ProviderInstanceId.make("acp-test-text-coalescing"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-text-coalescing"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-text-coalescing"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - assert.isDefined(sessionUpdateHandler); - const sendUpdate = (update: EffectAcpSchema.SessionNotification["update"]) => - sessionUpdateHandler!({ sessionId: "mock-session-1", update }); - const chunk = "tok0 word "; - - for (let index = 0; index < 1_000; index += 1) { - yield* sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: chunk }, - }); - } - yield* TestClock.adjust("50 millis"); - yield* sendUpdate({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "end" }, - }); - // Another item closes the assistant segment while its next flush is pending. - yield* sendUpdate({ - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "next" }, - }); - yield* TestClock.adjust("50 millis"); - yield* sendUpdate({ - sessionUpdate: "plan_update", - plan: { - type: "items", - planId: "plan-sentinel", - entries: [{ content: "Continue", priority: "medium", status: "in_progress" }], - }, - }); - const streamed: Array = []; - while (streamed.at(-1)?.type !== "plan.updated") { - streamed.push(yield* Queue.take(events)); - } - - const reply = chunk.repeat(1_000); - assert.deepEqual( - streamed.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" - ? [[event.turnItem.status, event.turnItem.text]] - : [], - ), - [ - ["running", chunk], - ["running", reply], - ["completed", `${reply}end`], - ], - ); - assert.deepEqual( - streamed.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [[event.message.streaming, event.message.text]] - : [], - ), - [ - [true, chunk], - [true, reply], - [false, `${reply}end`], - ], - ); - assert.deepEqual( - streamed.flatMap((event) => - event.type === "node.updated" && event.node.kind === "assistant_message" - ? [event.node.status] - : [], - ), - ["running", "completed"], - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.live( "loads the persisted ACP session during startup without creating a throwaway session", () => @@ -2751,45 +2286,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("cleans detached fixtures when an assertion aborts the test scope", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const fileSystem = yield* FileSystem.FileSystem; - let published: ReadonlyArray = []; - const failed = yield* Effect.scoped( - Effect.gen(function* () { - const commandPidPath = yield* fileSystem.makeTempFileScoped({ - prefix: "t3-acp-forced-failure-command-", - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), - ); - const fixture = NodeChildProcess.spawn( - "bash", - [ - "-c", - 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"', - "bash", - commandPidPath, - ], - { detached: true, stdio: "ignore" }, - ); - fixture.unref(); - published = Option.getOrThrow( - yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 2), - ); - return yield* Effect.fail("forced assertion failure"); - }), - ).pipe(Effect.exit); - - assert.isTrue(Exit.isFailure(failed)); - assert.isTrue( - Option.isSome(yield* waitForProcessesToExit(published)), - "detached cleanup finalizer must reap the Bash and sleep fixture", - ); - }).pipe(Effect.provide(layerTest)), - ); - it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -2865,240 +2361,40 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("reaps detached native work when the provider exits before explicit teardown", () => + it.effect("negotiates and executes optional native session forks through the ACP runtime", () => Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const commandPidPath = yield* fileSystem.makeTempFileScoped({ - prefix: "t3-acp-provider-exit-command-", - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), - ); - const instanceId = ProviderInstanceId.make("acp-test-provider-exit"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - ownDescendantProcessGroups: true, - ownDetachedProcessGroup: true, - processGroupTerminationGrace: 0, - environment: { - T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", - T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH: "1", - T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, - T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1", - }, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-provider-exit"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ) - .pipe(Effect.exit, Effect.forkScoped); - - const published = yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3); - assert.isTrue(Option.isSome(published), "detached fixture must publish all process IDs"); - const pids = Option.getOrThrow(published); - assert.isTrue( - Option.isSome(yield* waitForProcessesToExit(pids)), - "provider termination must reap the detached launcher, Bash, and sleep processes", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("surfaces reduced guarantee when delegated cgroup containment is unavailable", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - let containment: - | AcpSessionRuntime.AcpSessionRuntime["Service"]["processContainment"] - | undefined; - const instanceId = ProviderInstanceId.make("acp-test-cgroup-unavailable"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - linuxCgroupController: null, - mockAgentPath, - ownDescendantProcessGroups: true, - ownDetachedProcessGroup: true, - wrapRuntime: (runtime) => { - containment = runtime.processContainment; - return runtime; - }, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-unavailable"), - modelSelection, - runtimePolicy, - }); - yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); - assert.equal(containment, "process-ledger-reduced-guarantee"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("cleans a cgroup lease when the pre-exec join wrapper fails", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - let createCalls = 0; - let cgroupExists = true; - let killCalls = 0; - let removeCalls = 0; - const cgroupController: AcpSessionRuntime.AcpLinuxCgroupController = { - create: () => { - createCalls += 1; - return { - contains: () => false, - exists: () => cgroupExists, - path: "/definitely-missing/t3-acp-cgroup", - relativePath: "/definitely-missing/t3-acp-cgroup", - kill: () => { - killCalls += 1; - }, - populated: () => false, - remove: () => { - removeCalls += 1; - cgroupExists = false; - }, - }; - }, - }; - const instanceId = ProviderInstanceId.make("acp-test-cgroup-join-failure"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - linuxCgroupController: cgroupController, - mockAgentPath, - ownDescendantProcessGroups: true, - ownDetachedProcessGroup: true, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const sessionScope = yield* Scope.make(); - const opened = yield* adapter - .openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-join-failure"), - modelSelection, - runtimePolicy, - }) - .pipe(Effect.provideService(Scope.Scope, sessionScope), Effect.exit); - assert.isTrue(Exit.isFailure(opened)); - yield* Scope.close(sessionScope, Exit.void); - assert.equal(createCalls, 1); - assert.isAtLeast(killCalls, 1); - assert.isAtLeast(removeCalls, 1); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("negotiates and executes optional native session forks through the ACP runtime", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let createTerminal: Parameters[0] | undefined; - let readTerminalOutput: Parameters[0] | undefined; - let waitForTerminalExit: - | Parameters[0] - | undefined; - const makeRuntime = makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleCreateTerminal: (handler) => - Effect.sync(() => { - createTerminal = handler; - }).pipe(Effect.andThen(runtime.handleCreateTerminal(handler))), - handleTerminalOutput: (handler) => - Effect.sync(() => { - readTerminalOutput = handler; - }).pipe(Effect.andThen(runtime.handleTerminalOutput(handler))), - handleTerminalWaitForExit: (handler) => - Effect.sync(() => { - waitForTerminalExit = handler; - }).pipe(Effect.andThen(runtime.handleTerminalWaitForExit(handler))), - }), + const protocolEvents = yield* Queue.bounded(256); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let createTerminal: Parameters[0] | undefined; + let readTerminalOutput: Parameters[0] | undefined; + let waitForTerminalExit: + | Parameters[0] + | undefined; + const makeRuntime = makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + protocolEvents, + wrapRuntime: (runtime) => ({ + ...runtime, + handleCreateTerminal: (handler) => + Effect.sync(() => { + createTerminal = handler; + }).pipe(Effect.andThen(runtime.handleCreateTerminal(handler))), + handleTerminalOutput: (handler) => + Effect.sync(() => { + readTerminalOutput = handler; + }).pipe(Effect.andThen(runtime.handleTerminalOutput(handler))), + handleTerminalWaitForExit: (handler) => + Effect.sync(() => { + waitForTerminalExit = handler; + }).pipe(Effect.andThen(runtime.handleTerminalWaitForExit(handler))), + }), }); const instanceId = ProviderInstanceId.make("acp-test"); @@ -3483,61 +2779,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("fails missing native ACP session ids through the typed start-turn error channel", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test-missing-native-thread"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-missing-native-thread"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-missing-native-thread"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - const error = yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread: { ...providerThread, nativeThreadRef: null }, - instanceId, - runtimePolicy, - now, - }), - ) - .pipe(Effect.flip); - - assert.equal(error._tag, "ProviderAdapterTurnStartError"); - assert.instanceOf(error.cause, ProviderAdapter.ProviderAdapterProtocolError); - assert.include(String(error.cause), "missing its ACP session id"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.effect("replaces the ACP session and clears conversation state on rollback", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -3848,170 +3089,118 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("keeps the original ACP session usable when a staged replacement terminates", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - let runtimeOrdinal = 0; - const makeBaseRuntime = makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - }); - const makeRuntime: AcpAdapterV2Flavor["makeRuntime"] = (runtimeInput) => - Effect.gen(function* () { - const currentOrdinal = runtimeOrdinal + 1; - runtimeOrdinal = currentOrdinal; - const currentRuntime = yield* makeBaseRuntime(runtimeInput); - if (currentOrdinal === 1) return currentRuntime; - return { - ...currentRuntime, - start: () => - currentRuntime.start().pipe( - Effect.tap(() => - runtimeInput.onTermination( - new EffectAcpErrors.AcpTransportError({ - detail: "Forced staged rollback replacement termination", - cause: "test", - }), - ), - ), - ), - }; + // Oracle: Grok incident frames. Command completion must release the turn even + // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. + it.live( + "completes a structured Grok background command from poll with an unanswered prompt RPC", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-background-completion"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_GROK_BACKGROUND_TASK_COMPLETION: "poll" }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), }); - const instanceId = ProviderInstanceId.make("acp-test-rollback-failure-compensation"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime, - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-rollback-failure-compensation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-rollback-failure-compensation", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - - const rollback = yield* runtime - .rollbackThread({ - providerThread, - providerThreadTurns: [], - target: { - type: "thread_start", - checkpointId: CheckpointId.make("checkpoint-acp-rollback-failure-compensation"), - appRunOrdinal: 0, - }, - }) - .pipe(Effect.result); - assert.equal(rollback._tag, "Failure"); - - yield* runtime.startTurn( - makeTurnInput({ + const threadId = ThreadId.make("grok-background-completion"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ threadId, - providerThread, - instanceId, + providerSessionId: ProviderSessionId.make("grok-background-completion"), + modelSelection, runtimePolicy, - now: yield* DateTime.now, - }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId( - instanceId, - `${providerThread.nativeThreadRef?.nativeId}:turn:1`, - ), - }); - while (true) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - assert.equal(event.status, "completed"); - break; - } - } - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("closes an idle ACP session exactly once through the transition permit", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-idle-finalizer"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const sessionScope = yield* Scope.make(); - yield* adapter - .openSession({ + }); + const providerThread = yield* runtime.ensureThread({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"), modelSelection, runtimePolicy, - }) - .pipe(Effect.provideService(Scope.Scope, sessionScope)); - yield* pollProtocolMethods(protocolEvents); - yield* Scope.close(sessionScope, Exit.void); - const finalizerMethods = yield* pollProtocolMethods(protocolEvents); - assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1); - }).pipe(Effect.provide(layerTest)), + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const observed = yield* Stream.fromQueue(events).pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const allEvents = [...observed]; + let next = yield* Queue.poll(events); + while (Option.isSome(next)) { + allEvents.push(next.value); + next = yield* Queue.poll(events); + } + const terminals = allEvents.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.status), + ["completed"], + ); + const items = allEvents.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.isFalse(items.some((item) => item.type === "subagent")); + const command = items.findLast( + (item) => item.nativeItemRef?.nativeId === "call-fb9d0000-0000-0000-0000-000000000026-29", + ); + assert.equal(command?.type, "command_execution"); + assert.equal(command?.status, "completed"); + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId === "call-background-poll"), + "command TaskOutput must retain its normal tool frame", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect.each(["grok-build", "composer-2"])( - "Grok configures the native session for %s", - (model) => + // Oracle: #15888 KillTask reports. Command completion must release the turn even + // when the CLI keeps the prompt RPC and process alive. Kill calls stay visible. + it.live.each([ + { shape: "output_only", expectedCommand: { type: "command_execution", status: "completed" } }, + { shape: "variant_only", expectedCommand: { type: "command_execution", status: "completed" } }, + { shape: "killed", expectedCommand: { type: "command_execution", status: "completed" } }, + { + shape: "already_exited", + expectedCommand: { type: "command_execution", status: "completed" }, + }, + { + shape: "known_failure", + expectedCommand: { type: "command_execution", status: "failed", output: "known failure\n" }, + }, + ] as const)( + "completes a Grok KillTask command ($shape) and preserves any known result", + ({ shape, expectedCommand }) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.unbounded(); - const instanceId = ProviderInstanceId.make("grok-test"); + const instanceId = ProviderInstanceId.make("grok-kill-completion"); const adapter = yield* makeGrokAdapterV2({ instanceId, settings: DEFAULT_GROK_SETTINGS, @@ -4019,74 +3208,35 @@ describe("AcpAdapterV2", () => { hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), makeRuntime: (input) => - makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( - Effect.flatMap(makeXAiPromptCompletionRuntime), - ), + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_GROK_KILL_TASK_SHAPE: shape }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), }); - yield* adapter.openSession({ - threadId: ThreadId.make(`grok-model-${model}`), - providerSessionId: ProviderSessionId.make(`grok-model-${model}`), - modelSelection: { instanceId, model }, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }), + const threadId = ThreadId.make("grok-kill-completion"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), }); - const requests = (yield* Queue.takeAll(protocolEvents)).map(rawProtocolRequest); - assert.equal( - requests.filter( - (request) => - request?.method === "session/set_config_option" && - typeof request.params === "object" && - request.params !== null && - "configId" in request.params && - request.params.configId === "model", - ).length, - model === "grok-build" ? 0 : 1, + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-kill-completion"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("Grok reapplies an explicit return to the session's setup-time model", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.unbounded(); - const instanceId = ProviderInstanceId.make("grok-test-switch-back"); - const adapter = yield* makeGrokAdapterV2({ - instanceId, - settings: DEFAULT_GROK_SETTINGS, - environment: {}, - hostPlatform: yield* HostProcess.Platform, - selfInvocation: yield* resolveSelfInvocation(), - makeRuntime: (input) => - makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( - Effect.flatMap(makeXAiPromptCompletionRuntime), - ), - }); - const threadId = ThreadId.make("grok-model-switch-back"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("grok-model-switch-back"), - modelSelection: { instanceId, model: "composer-2" }, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: { instanceId, model: "composer-2" }, - runtimePolicy, - }); - for (const model of ["default", "composer-2"]) { yield* runtime.startTurn( makeTurnInput({ threadId, @@ -4094,69 +3244,39 @@ describe("AcpAdapterV2", () => { instanceId, runtimePolicy, now: yield* DateTime.now, - modelSelection: { instanceId, model }, }), ); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, + const observed = yield* Stream.fromQueue(events).pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, ); - } - const requests = (yield* Queue.takeAll(protocolEvents)).map(rawProtocolRequest); - assert.equal( - requests.filter( - (request) => - request?.method === "session/set_config_option" && - typeof request.params === "object" && - request.params !== null && - "configId" in request.params && - request.params.configId === "model", - ).length, - 3, - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("skips requested options that the active ACP session does not expose", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-unsupported-option"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), - modelSelection: { - instanceId, - model: "default", - options: [{ id: "missing-option", value: "high" }], - }, - runtimePolicy, - }); - - assert.equal(runtime.providerSession.status, "ready"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + const allEvents = [...observed]; + let next = yield* Queue.poll(events); + while (Option.isSome(next)) { + allEvents.push(next.value); + next = yield* Queue.poll(events); + } + const terminals = allEvents.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.status), + ["completed"], + ); + const items = allEvents.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.isFalse(items.some((item) => item.type === "subagent")); + const command = items.findLast( + (item) => item.nativeItemRef?.nativeId === "kill-target-command", + ); + assert.deepInclude(command, expectedCommand); + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId === "kill-command"), + "KillTask must retain its normal tool frame", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => + it.effect("keeps an agent's empty-valued default choice selectable", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; @@ -4166,151 +3286,47 @@ describe("AcpAdapterV2", () => { ); const protocolEvents = yield* Queue.bounded(256); const instanceId = ProviderInstanceId.make("acp-test"); + let agentDescriptor: ProviderOptionDescriptor | undefined; const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION: "1" }, + protocolEvents, + }), + onSessionConfigurationUpdate: (configOptions, modeState) => + Effect.sync(() => { + agentDescriptor = acpProviderOptionDescriptors({ configOptions, modeState }).find( + (descriptor) => descriptor.id === "agent", + ); + }), }, selfInvocation, }); - const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); - const secondThreadId = ThreadId.make("thread-acp-active-setup:second"); + const threadId = ThreadId.make("thread-acp-empty-default-agent"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), }); - const initialSelection = { instanceId, model: "default" } satisfies ModelSelection; - const alternateSelection = { - instanceId, - model: "composer-2", - } satisfies ModelSelection; - const originalSelection = { + const customAgentSelection = { instanceId, - model: "gpt-5.3-codex[reasoning=medium,fast=false]", + model: "default", + options: [{ id: "agent", value: "example" }], } satisfies ModelSelection; const runtime = yield* adapter.openSession({ - threadId: firstThreadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-active-setup"), - modelSelection: initialSelection, + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-empty-default-agent"), + modelSelection: customAgentSelection, runtimePolicy, }); - const firstProviderThread = yield* runtime.ensureThread({ - threadId: firstThreadId, - modelSelection: initialSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId: firstThreadId, - providerThread: firstProviderThread, - instanceId, - runtimePolicy, - modelSelection: alternateSelection, - now, - }), - ); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - - const secondProviderThread: OrchestrationV2ProviderThread = { - ...firstProviderThread, - id: ProviderThreadId.make("provider-thread-acp-active-setup:second"), - appThreadId: secondThreadId, - nativeThreadRef: { - driver: ACP_TEST_DRIVER, - nativeId: "mock-session-2", - strength: "strong", - }, - status: "idle", - }; - yield* runtime.resumeThread({ - providerThread: secondProviderThread, - modelSelection: alternateSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId: secondThreadId, - providerThread: secondProviderThread, - instanceId, - runtimePolicy, - modelSelection: originalSelection, - now, - ordinal: 2, - }), - ); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - - const modelConfigurationRequests = Array.from(yield* Queue.takeAll(protocolEvents)).filter( - (event) => - event.direction === "outgoing" && - rawProtocolMethod(event) === "session/set_config_option" && - rawProtocolRequestParam(event, "configId") === "model", - ); - assert.lengthOf(modelConfigurationRequests, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("keeps an agent's empty-valued default choice selectable", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - let agentDescriptor: ProviderOptionDescriptor | undefined; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION: "1" }, - protocolEvents, - }), - onSessionConfigurationUpdate: (configOptions, modeState) => - Effect.sync(() => { - agentDescriptor = acpProviderOptionDescriptors({ configOptions, modeState }).find( - (descriptor) => descriptor.id === "agent", - ); - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-empty-default-agent"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const customAgentSelection = { - instanceId, - model: "default", - options: [{ id: "agent", value: "example" }], - } satisfies ModelSelection; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-empty-default-agent"), - modelSelection: customAgentSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: customAgentSelection, + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: customAgentSelection, runtimePolicy, }); assert.deepEqual(agentDescriptor, { @@ -4793,88 +3809,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("carries elicitation request identity through the completed stdout write", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const responseWritten = yield* Deferred.make(); - const releaseResponseAcknowledgement = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_ELICITATION: "1" }, - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseWritten, undefined).pipe( - Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), - Effect.andThen(onOutgoingResponse(requestId)), - ), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-reordered-elicitation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-reordered-elicitation"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const pending = Option.getOrThrow( - yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", - ), - Stream.runHead, - ), - ); - if (pending.type !== "runtime_request.updated") { - return yield* Effect.die("Expected a pending elicitation request"); - } - const responseFiber = yield* runtime - .respondToRuntimeRequest({ - requestId: pending.runtimeRequest.id, - answers: { approved: ["true"] }, - }) - .pipe(Effect.forkScoped); - - yield* Deferred.await(responseWritten); - assert.isUndefined(responseFiber.pollUnsafe()); - yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); - yield* Fiber.join(responseFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.live("auto-approves tagged MCP elicitations under full-access policy", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -5037,6791 +3971,537 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("rejects delayed native response registration when normal close wins the permit", () => + it.live("treats a second hard Stop as success when the turn is already gone", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const registrationStarted = yield* Deferred.make(); - const releaseRegistration = yield* Deferred.make(); - const transportClosed = yield* Deferred.make(); - const releaseTransportClose = yield* Deferred.make(); - const responseLifecycle: Array = []; - const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration"); + const protocolEvents = yield* Queue.bounded(256); + const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, + interruptPromptOnCancel: false, + restartRuntimeAfterInterrupt: true, + terminateRuntimeProcessGroupOnInterrupt: true, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, + ownDetachedProcessGroup: true, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + protocolEvents, }), }, selfInvocation, - testHooks: { - afterNativeResponseTransportClosed: () => - Deferred.succeed(transportClosed, undefined).pipe( - Effect.andThen(Deferred.await(releaseTransportClose)), - ), - beforeNativeResponseAdmissionCheck: () => - Deferred.succeed(registrationStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRegistration)), - ), - onNativeResponseLifecycle: (event) => - Effect.sync(() => { - responseLifecycle.push(event.type); - }), - }, }); - const threadId = ThreadId.make("thread-acp-close-wins-registration"); + const threadId = ThreadId.make("thread-acp-double-stop"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", + runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), }); const modelSelection = { instanceId, model: "default" } as const; - const sessionScope = yield* Scope.make(); - const runtime = yield* adapter - .openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-close-wins-registration"), - modelSelection, - runtimePolicy, - }) - .pipe(Effect.provideService(Scope.Scope, sessionScope)); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy, }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, + const now = yield* DateTime.now; + yield* runtime + .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now })) + .pipe(Effect.forkScoped); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId, + requestRuntimeRestart: true, + }); + const second = yield* Effect.exit( + runtime.interruptTurn({ providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, + providerTurnId, + requestRuntimeRestart: true, }), ); - const pending = Option.getOrThrow( - yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", - ), - Stream.runHead, - ), - ); - if (pending.type !== "runtime_request.updated") { - return yield* Effect.die("Expected a pending permission request"); + assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent"); + let terminal: string | null = null; + while (terminal === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + terminal = event.status; + } } - const responseFiber = yield* runtime - .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) - .pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.await(registrationStarted); - - const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); - yield* Deferred.await(transportClosed); - yield* Deferred.succeed(releaseRegistration, undefined); - while (!responseLifecycle.includes("admission_rejected")) { - yield* Effect.yieldNow; - } - const responseExit = yield* Fiber.join(responseFiber); - if (Exit.isSuccess(responseExit)) { - assert.fail("normal close must reject a response delayed before transport registration"); - } - assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed"); - assert.equal(responseLifecycle.filter((event) => event === "registered").length, 1); - assert.equal(responseLifecycle.filter((event) => event === "removed").length, 1); - assert.equal(responseLifecycle.filter((event) => event === "admission_rejected").length, 1); - yield* Deferred.succeed(releaseTransportClose, undefined); - yield* Fiber.join(closeFiber); + assert.equal(terminal, "interrupted"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("bounds a missing pending permission response acknowledgement", () => + it.effect("finalizes a settled turn held open for background work when interrupted", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const responseEnqueued = yield* Deferred.make(); - const releaseNativeHook = yield* Deferred.make(); - const responseLifecycle: Array = []; - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout"); + const instanceId = ProviderInstanceId.make("acp-test"); + const promptSettled = yield* Deferred.make(); const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { - T3_ACP_EMIT_TOOL_CALLS: "1", - T3_ACP_HANG_AFTER_PERMISSION: "1", - }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - protocolEvents, - windowsProcessTreeTerminator: (pid) => - Deferred.succeed(releaseNativeHook, undefined).pipe( - Effect.andThen( - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - ), - ), - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseEnqueued, undefined).pipe( - Effect.andThen(Deferred.await(releaseNativeHook)), - Effect.andThen(onOutgoingResponse(requestId)), - ), - }), - }, - selfInvocation, testHooks: { - onNativeResponseLifecycle: (event) => - Effect.sync(() => { - responseLifecycle.push(event.type); - }), + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, - }); - const threadId = ThreadId.make("thread-acp-pending-response-timeout"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const sessionScope = yield* Scope.make(); - const runtime = yield* adapter - .openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-pending-response-timeout", - ), - modelSelection, - runtimePolicy, - }) - .pipe(Effect.provideService(Scope.Scope, sessionScope)); - yield* pollProtocolMethods(protocolEvents); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const pending = Option.getOrThrow( - yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", - ), - Stream.runHead, - ), - ); - if ( - pending.type !== "runtime_request.updated" || - pending.runtimeRequest.providerTurnId === null - ) { - return yield* Effect.die("Expected a pending permission request"); - } - const responseExit = yield* runtime - .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) - .pipe(Effect.exit); - if (Exit.isSuccess(responseExit)) { - assert.fail("missing native response acknowledgement must fail the pending response"); - } - assert.include(Cause.pretty(responseExit.cause), "Native response acknowledgement timed out"); - assert.isTrue(yield* Deferred.isDone(responseEnqueued)); - assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); - assert.isBelow(responseLifecycle.indexOf("removed"), responseLifecycle.indexOf("failed")); - assert.includeMembers(responseLifecycle, [ - "registered", - "removed", - "failed", - "timer_exited", - "timer_started", - "watcher_exited", - "watcher_started", - ]); - - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: pending.runtimeRequest.providerTurnId, - requestRuntimeRestart: true, - }); - assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); - yield* Effect.sleep("50 millis"); - assert.include(responseLifecycle, "late_noop"); - yield* Scope.close(sessionScope, Exit.void); - assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/close"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("defers caller cancellation until a pending response acknowledgement is bounded", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const responseEnqueued = yield* Deferred.make(); - const releaseNativeHook = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel"); - const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, + deferFinalizeForBackgroundWork: true, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId === "tool-call-generic-1" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId: null, + result: null, + } + : undefined, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - environment: { - T3_ACP_EMIT_TOOL_CALLS: "1", - T3_ACP_HANG_AFTER_PERMISSION: "1", - }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: (pid) => - Deferred.succeed(releaseNativeHook, undefined).pipe( - Effect.andThen( - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - ), - ), - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseEnqueued, undefined).pipe( - Effect.andThen(Deferred.await(releaseNativeHook)), - Effect.andThen(onOutgoingResponse(requestId)), - ), + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, }), }, selfInvocation, }); - const threadId = ThreadId.make("thread-acp-pending-response-cancel"); + const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", + runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), }); const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-pending-response-cancel"), + providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"), modelSelection, runtimePolicy, }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy, }); + const now = yield* DateTime.now; yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const pending = Option.getOrThrow( - yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", - ), - Stream.runHead, - ), - ); - if ( - pending.type !== "runtime_request.updated" || - pending.runtimeRequest.providerTurnId === null - ) { - return yield* Effect.die("Expected a pending permission request"); - } - const responseFiber = yield* runtime - .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) - .pipe(Effect.forkScoped); - yield* Deferred.await(responseEnqueued); - const cancellationFiber = yield* Fiber.interrupt(responseFiber).pipe(Effect.forkScoped); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: pending.runtimeRequest.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Effect.sleep("100 millis"); - assert.isUndefined(cancellationFiber.pollUnsafe()); - assert.isUndefined(interruptFiber.pollUnsafe()); - assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); - - yield* Fiber.join(cancellationFiber); - yield* Fiber.join(interruptFiber); - assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("waits for immediate allow and deny permission responses before hard teardown", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); + yield* Deferred.await(promptSettled); - for (const [name, sandboxPolicy] of [ - ["allow", undefined], - ["deny", { type: "readOnly" } as const], - ] as const) { - yield* Effect.gen(function* () { - const responseEnqueued = yield* Deferred.make(); - const releaseResponseAcknowledgement = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make(`acp-test-${name}`); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: (pid) => - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseEnqueued, undefined).pipe( - Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), - Effect.andThen(onOutgoingResponse(requestId)), - ), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - approvalPolicy: "never", - cwd: process.cwd(), - ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - `provider-session-acp-immediate-permission-${name}`, - ), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const turnFiber = yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ) - .pipe(Effect.forkDetach); - yield* Deferred.await(responseEnqueued); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }), - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - assert.isUndefined(interruptFiber.pollUnsafe()); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId }); - yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); - yield* Fiber.join(interruptFiber); - yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); - }).pipe(Effect.scoped); + let terminalStatus: string | null = null; + while (terminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + terminalStatus = event.status; + } } + assert.equal(terminalStatus, "interrupted"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("waits for immediate URL elicitation responses before hard teardown", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const responseEnqueued = yield* Deferred.make(); - const releaseResponseAcknowledgement = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test-url-elicitation"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_URL_ELICITATION: "1" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: (pid) => - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseEnqueued, undefined).pipe( - Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), - Effect.andThen(onOutgoingResponse(requestId)), - ), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-immediate-url-elicitation"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const turnFiber = yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ) - .pipe(Effect.forkDetach); - yield* Deferred.await(responseEnqueued); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ + it.effect( + "carries a live subagent lineage across an interrupt so the next turn can complete it", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test"); + let subagentPhase: "spawn" | "complete" = "spawn"; + const promptSettled = yield* Deferred.make(); + const adapter = yield* makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, + instanceId, + flavor: { driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }), - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - assert.isUndefined(interruptFiber.pollUnsafe()); - - yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); - yield* Fiber.join(interruptFiber); - yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId !== "tool-call-generic-1" + ? undefined + : subagentPhase === "spawn" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId: null, + result: null, + } + : { + nativeTaskId: "task-generic-1", + prompt: "", + title: null, + model: null, + status: "completed", + childSessionId: null, + result: "SUB_DONE", + }, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-subagent-carryover"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Deferred.await(promptSettled); - it.live("bounds a missing immediate response acknowledgement before hard teardown", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const responseEnqueued = yield* Deferred.make(); - const releaseNativeHook = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { + const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: (pid) => - Deferred.succeed(releaseNativeHook, undefined).pipe( - Effect.andThen( - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - ), - ), - wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => - Deferred.succeed(responseEnqueued, undefined).pipe( - Effect.andThen(Deferred.await(releaseNativeHook)), - Effect.andThen(onOutgoingResponse(requestId)), - ), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-missing-response-ack"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - approvalPolicy: "never", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-missing-response-ack"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const turnFiber = yield* runtime - .startTurn( + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); + + let subagentTurnItemId: string | null = null; + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { + subagentTurnItemId = event.turnItem.id; + } + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; + } + } + assert.equal(firstTerminalStatus, "interrupted"); + assert.notEqual(subagentTurnItemId, null); + + subagentPhase = "complete"; + const secondNow = yield* DateTime.now; + yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, - now: yield* DateTime.now, + now: secondNow, + ordinal: 2, }), - ) - .pipe(Effect.forkDetach); - yield* Deferred.await(responseEnqueued); - const startedAt = yield* Clock.currentTimeMillis; - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }), - requestRuntimeRestart: true, - }); - assert.isAtLeast((yield* Clock.currentTimeMillis) - startedAt, 1_500); - yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("rejects an elicitation response when hard teardown wins admission", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const teardownStarted = yield* Deferred.make(); - const releaseTeardown = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_ELICITATION: "1" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: (pid) => - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseTeardown)), - Effect.andThen( - Effect.sync(() => { - process.kill(pid, "SIGTERM"); - }), - ), - ), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-teardown-wins-elicitation"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const pending = Option.getOrThrow( - yield* runtime.events.pipe( - Stream.filter( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", - ), - Stream.runHead, - ), - ); - if ( - pending.type !== "runtime_request.updated" || - pending.runtimeRequest.providerTurnId === null - ) { - return yield* Effect.die("Expected a pending elicitation request"); - } - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: pending.runtimeRequest.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(teardownStarted); - const responseFiber = yield* runtime - .respondToRuntimeRequest({ - requestId: pending.runtimeRequest.id, - answers: { approved: ["true"] }, - }) - .pipe(Effect.exit, Effect.forkScoped); - yield* Effect.yieldNow; - assert.isUndefined(responseFiber.pollUnsafe()); - - yield* Deferred.succeed(releaseTeardown, undefined); - yield* Fiber.join(interruptFiber); - const responseExit = yield* Fiber.join(responseFiber); - if (Exit.isSuccess(responseExit)) { - assert.fail("teardown winning admission must reject the elicitation response"); - } - assert.include(Cause.pretty(responseExit.cause), "No pending ACP runtime request"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("releases an ACP turn when cancellation times out", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - const protocolEvents = yield* Queue.bounded(256); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, - protocolEvents, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-cancel-timeout"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - const firstTurn = makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - }); - yield* runtime.startTurn(firstTurn); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", - ), - Stream.runHead, - ); - yield* TestClock.adjust("10 seconds"); - const interruptError = yield* Fiber.join(interruptFiber); - assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); - - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - ordinal: 2, - }), - ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("treats a second hard Stop as success when the turn is already gone", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - interruptPromptOnCancel: false, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - ownDetachedProcessGroup: true, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-double-stop"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now })) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ - providerThread, - providerTurnId, - requestRuntimeRestart: true, - }); - const second = yield* Effect.exit( - runtime.interruptTurn({ - providerThread, - providerTurnId, - requestRuntimeRestart: true, - }), - ); - assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent"); - let terminal: string | null = null; - while (terminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminal = event.status; - } - } - assert.equal(terminal, "interrupted"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("finalizes a settled turn held open for background work when interrupted", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId === "tool-call-generic-1" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : undefined, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId }); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "interrupted"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "carries a live subagent lineage across an interrupt so the next turn can complete it", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-subagent-carryover"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let subagentTurnItemId: string | null = null; - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - subagentTurnItemId = event.turnItem.id; - } - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.notEqual(subagentTurnItemId, null); - - subagentPhase = "complete"; - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let carriedItemStatus: string | null = null; - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.id === subagentTurnItemId - ) { - carriedItemStatus = event.turnItem.status; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.equal(carriedItemStatus, "completed"); - assert.equal(secondTerminalStatus, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "pins hasPendingBackgroundWork while carryover holds a live subagent after root settle", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-carryover-pending-pin"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-pending-pin"), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - - subagentPhase = "complete"; - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.equal(secondTerminalStatus, "completed"); - assert.isFalse( - yield* hasPendingBackgroundWork, - "hasPendingBackgroundWork must clear after carryover subagent terminals", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("handles a child terminal after carryover rehydrate", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const secondPromptWireReturned = yield* Deferred.make(); - const releaseSecondPromptCompletion = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "mock-child-session-active-carryover"; - let promptCount = 0; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: (payload) => - Effect.gen(function* () { - const currentPrompt = ++promptCount; - const result = yield* runtime.prompt(payload); - if (currentPrompt === 2) { - yield* Deferred.succeed(secondPromptWireReturned, undefined); - yield* Deferred.await(releaseSecondPromptCompletion); - } - return result; - }), - }), - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-active-carryover-pending-pin"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-active-carryover-pending-pin", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - const secondNow = yield* DateTime.now; - const secondTurnFiber = yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(secondPromptWireReturned); - assert.isTrue( - yield* hasPendingBackgroundWork, - "rehydrated live subagent must pin from activeTurn", - ); - while (Option.isSome(yield* Queue.poll(events))) {} - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - subagentPhase = "spawn"; - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "in_progress", - rawOutput: {}, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isFalse( - yield* hasPendingBackgroundWork, - "active-turn pin must clear after the child-session terminal is projected", - ); - let completedSubagentUpdates = 0; - let resurrectedSubagentUpdates = 0; - let normalChildToolUpdates = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if (event.type === "turn_item.updated") { - if (event.turnItem.type === "subagent") { - if (event.turnItem.status === "completed") { - completedSubagentUpdates += 1; - } - if ( - event.turnItem.status === "running" && - event.turnItem.nativeItemRef?.nativeId === "task-generic-1" - ) { - resurrectedSubagentUpdates += 1; - } - } else if (event.turnItem.nativeItemRef?.nativeId === "tool-call-generic-1") { - normalChildToolUpdates += 1; - } - } - polled = yield* Queue.poll(events); - } - assert.equal(completedSubagentUpdates, 1); - assert.equal(resurrectedSubagentUpdates, 0); - assert.equal(normalChildToolUpdates, 0); - - yield* Deferred.succeed(releaseSecondPromptCompletion, undefined); - yield* Fiber.join(secondTurnFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "projects a child terminal in the completed-root finalization window exactly once", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const baseClock = yield* Clock.Clock; - const finalizationClockRead = yield* Deferred.make(); - const releaseFinalizationClockRead = yield* Deferred.make(); - let blockNextClockRead = false; - const blockingClock: Clock.Clock = { - ...baseClock, - currentTimeMillis: Effect.suspend(() => { - if (!blockNextClockRead) return baseClock.currentTimeMillis; - blockNextClockRead = false; - return Deferred.succeed(finalizationClockRead, undefined).pipe( - Effect.andThen(Deferred.await(releaseFinalizationClockRead)), - Effect.andThen(baseClock.currentTimeMillis), - ); - }), - }; - yield* Effect.gen(function* () { - const instanceId = ProviderInstanceId.make("acp-test"); - const firstChildSessionId = "mock-child-session-finalize-window-first"; - const secondChildSessionId = "mock-child-session-finalize-window-second"; - let firstSubagentStatus: "running" | "completed" = "running"; - let secondSubagentStatus: "waiting" | "completed" = "waiting"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: - | Parameters[0] - | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => { - if (toolCall.toolCallId === "tool-call-generic-1") { - return { - nativeTaskId: "task-finalize-window-first", - prompt: "first background subagent", - title: "first background subagent", - model: null, - status: firstSubagentStatus, - childSessionId: firstChildSessionId, - result: firstSubagentStatus === "completed" ? "FIRST_DONE" : null, - suppressNormalTool: true, - }; - } - if (toolCall.toolCallId === "tool-call-generic-2") { - return { - nativeTaskId: "task-finalize-window-second", - prompt: "second background subagent", - title: "second background subagent", - model: null, - status: secondSubagentStatus, - childSessionId: secondChildSessionId, - result: secondSubagentStatus === "completed" ? "SECOND_DONE" : null, - suppressNormalTool: true, - } as AcpAdapterV2SubagentUpdate; - } - return undefined; - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-subagent-finalize-window"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-subagent-finalize-window", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - yield* TestClock.adjust("1 second"); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - firstSubagentStatus = "completed"; - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "first background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "FIRST_DONE" }, - }, - }).pipe(Effect.provideService(Clock.Clock, blockingClock)); - for (let attempt = 0; attempt < 10; attempt += 1) { - yield* Effect.yieldNow; - } - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-2", - title: "second background subagent", - kind: "other", - status: "in_progress", - rawOutput: {}, - }, - }); - assert.isTrue(yield* hasPendingBackgroundWork); - - while (Option.isSome(yield* Queue.poll(events))) {} - - blockNextClockRead = true; - const adjustFiber = yield* TestClock.adjust("3 seconds").pipe(Effect.forkScoped); - yield* Deferred.await(finalizationClockRead); - secondSubagentStatus = "completed"; - yield* sessionUpdateHandler!({ - sessionId: secondChildSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-2", - title: "second background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SECOND_DONE" }, - }, - }); - yield* Deferred.succeed(releaseFinalizationClockRead, undefined); - yield* Fiber.join(adjustFiber); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let completedSubagentUpdates = 0; - let rootTerminalStatus: string | null = null; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.nativeItemRef?.nativeId === "task-finalize-window-second" && - event.turnItem.status === "completed" - ) { - completedSubagentUpdates += 1; - } - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - rootTerminalStatus = event.status; - } - polled = yield* Queue.poll(events); - } - assert.equal(rootTerminalStatus, "completed"); - assert.equal(completedSubagentUpdates, 1); - assert.isFalse( - yield* hasPendingBackgroundWork, - "finalize-window terminal must clear the carryover pin", - ); - }).pipe(Effect.provideService(Clock.Clock, blockingClock)); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "defers an interrupted pending-spawn carryover terminal until the next observable attach", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const promptSettled = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "mock-child-session-post-settle"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "pending", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-child-session-post-settle"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-carryover-child-session-post-settle", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - let completedBeforeAttach = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedBeforeAttach += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedBeforeAttach, - 0, - "closed interrupted subscription must not be treated as projected", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "terminal carryover must stay pinned until an attach can project it", - ); - assert.lengthOf( - continuationRequests, - 0, - "child-session completion must not open a root continuation", - ); - - const attachNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: attachNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Attach deferred completion.", - }), - ); - const attachProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let attachTerminal: string | null = null; - let completedAfterAttach = 0; - while (attachTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedAfterAttach += 1; - } - if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) { - attachTerminal = event.status; - } - } - assert.equal(attachTerminal, "completed"); - assert.equal(completedAfterAttach, 1); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("finishes a settled root's carryover subagent from its structured end", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd"; - let finishSubagent: AcpAdapterV2ExtensionContext["finishSubagent"] | undefined; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - }, - registerExtensions: (context) => - Effect.sync(() => { - finishSubagent = context.finishSubagent; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-carryover-subagent-finished"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-finished"), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die("post-settle continuation must expose hasPendingBackgroundWork"); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let rootStatus: string | null = null; - while (rootStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - rootStatus = event.status; - } - } - assert.equal(rootStatus, "completed"); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.isDefined(finishSubagent); - const subagentStatuses = Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Effect.yieldNow; - const statuses: Array<[string, string | null]> = []; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - statuses.push([event.turnItem.status, event.turnItem.result]); - } - polled = yield* Queue.poll(events); - } - return statuses; - }); - yield* subagentStatuses; - - yield* finishSubagent!({ - sessionId: "some-other-session", - childSessionId, - status: "completed", - result: "WRONG_PARENT", - }); - assert.deepEqual(yield* subagentStatuses, [], "non-root notices must be dropped"); - assert.isTrue(yield* hasPendingBackgroundWork); - - yield* finishSubagent!({ - sessionId: "mock-session-1", - childSessionId, - status: "failed", - result: "tool crashed", - }); - assert.deepEqual( - yield* subagentStatuses, - [["failed", "tool crashed"]], - "the completed root still owns the run, so the carryover end projects at once", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "a finished carryover subagent stops pinning", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("projects completed-root carryover eagerly and drain cannot resurrect it", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - normalizeToolCall: normalizeXAiAcpToolCallState, - extractSubagentUpdate: (toolCall) => - extractXAiAcpSubagentUpdate(toolCall) ?? - (toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-completed-root-eager-carryover"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-completed-root-eager-carryover", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* TestClock.adjust("1 second"); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "completed"); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - // A production Grok spawn ACK has raw tool status completed but extracts - // as a running subagent with its original non-empty prompt. - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-generic-1", - title: "spawn_subagent", - kind: "other", - status: "completed", - rawInput: { - description: "background subagent", - prompt: "background subagent", - subagent_type: "general-purpose", - }, - rawOutput: { - type: "Text", - text: [ - "Subagent started in background.", - `subagent_id: ${childSessionId}`, - "type: general-purpose", - "description: background subagent", - "", - `Use get_command_or_subagent_output with task_ids=["${childSessionId}"] and timeout_ms to wait for results.`, - ].join("\n"), - }, - }, - }); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update" as const, - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other" as const, - status: "completed" as const, - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let eagerCompletedUpdates = 0; - let eagerRunningUpdates = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - if (event.turnItem.status === "completed") eagerCompletedUpdates += 1; - if (event.turnItem.status === "running") eagerRunningUpdates += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal(eagerCompletedUpdates, 1, "completed root must project before any attach"); - assert.equal(eagerRunningUpdates, 0); - assert.lengthOf(continuationRequests, 1); - assert.isTrue(yield* hasPendingBackgroundWork, "buffered spawn ACK still requires a drain"); - - const continuationNow = yield* DateTime.now; - const continuationInput = makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: continuationNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }); - yield* runtime.startTurn(continuationInput); - yield* Effect.yieldNow; - let replayedLineages = 0; - let replayedSubagentUpdates = 0; - let replayedTurnItems = 0; - polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if (event.type === "app_thread.created") { - replayedLineages += 1; - } - if (event.type === "subagent.updated" && event.subagent.runId === continuationInput.runId) { - replayedSubagentUpdates += 1; - } - if ( - event.type === "turn_item.updated" && - event.turnItem.runId === continuationInput.runId - ) { - replayedTurnItems += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal(replayedLineages, 0, "buffered spawn ACK must not create a second lineage"); - assert.equal( - replayedSubagentUpdates, - 0, - "buffered spawn ACK must not re-open the terminal subagent", - ); - assert.equal( - replayedTurnItems, - 0, - "buffered spawn ACK must not create a continuation-owned turn item", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "user attach flushes a deferred terminal but leaves wake traffic for its continuation", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-root-session-continuation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-carryover-root-session-continuation", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: bufferedAssistantText }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let completedBeforeDrain = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedBeforeDrain += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedBeforeDrain, - 0, - "interrupted root-session terminal must wait for an observable attach", - ); - assert.lengthOf( - continuationRequests, - 1, - "root-session post-settle subagent terminal must offer a continuation", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "interrupted unprojected terminal must pin hasPendingBackgroundWork until attach", - ); - - const userTurnNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: userTurnNow, - ordinal: 2, - messageText: "What finished while the prior turn was settling?", - }), - ); - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const userProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let userTurnTerminal: string | null = null; - let completedSubagentTurnItems = 0; - let bufferedTextSeenInUserTurn = false; - while (userTurnTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes(bufferedAssistantText) - ) { - bufferedTextSeenInUserTurn = true; - } - if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) { - userTurnTerminal = event.status; - } - } - assert.equal(userTurnTerminal, "completed"); - assert.equal( - completedSubagentTurnItems, - 1, - "user attach must project the deferred terminal subagent turn item once", - ); - assert.isFalse(bufferedTextSeenInUserTurn, "user attach must not drain wake traffic"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "wake traffic must remain pinned for the already-dispatched continuation", - ); - - const continuationNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: continuationNow, - ordinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }), - ); - yield* TestClock.adjust("3 seconds"); - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"), - }); - let continuationTerminal: string | null = null; - let bufferedTextSeenInContinuation = false; - while (continuationTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes(bufferedAssistantText) - ) { - bufferedTextSeenInContinuation = true; - } - if ( - event.type === "turn.terminal" && - event.providerTurnId === continuationProviderTurnId - ) { - continuationTerminal = event.status; - } - } - assert.equal(continuationTerminal, "completed"); - assert.isTrue( - bufferedTextSeenInContinuation, - "queued continuation must drain the wake content after the user run", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "projects a carryover terminal when an in-turn-handled tool is re-reported post-settle", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - const promptWireReturned = yield* Deferred.make(); - const releasePromptCompletion = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-generic-1" ? "task-generic-1" : undefined, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: (payload) => - Effect.gen(function* () { - const result = yield* runtime.prompt(payload); - yield* Deferred.succeed(promptWireReturned, undefined); - yield* Deferred.await(releasePromptCompletion); - return result; - }), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-already-handled-re-report"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-carryover-already-handled-re-report", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptWireReturned); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "IN_TURN_RESULT" }, - }, - }); - yield* Deferred.succeed(releasePromptCompletion, undefined); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "live carryover must remain pending after the superseded root settles", - ); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let completedSubagentTurnItems = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedSubagentTurnItems, - 0, - "an interrupted root's non-buffered re-report must wait for an observable attach", - ); - assert.lengthOf( - continuationRequests, - 0, - "an in-turn-handled re-report must not offer a continuation", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "the unprojected terminal carryover must remain pinned", - ); - - const attachNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: attachNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Attach deferred completion.", - }), - ); - const attachProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let attachTerminal: string | null = null; - while (attachTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) { - attachTerminal = event.status; - } - } - assert.equal(completedSubagentTurnItems, 1); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("projects an interrupted root-session end notice at the next attach", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const promptSettled = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "019f5470-bf92-7a90-afb3-5a6cea5b34a3"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentEndNotice: (text) => { - const parsed = extractXAiAcpSubagentEndNotice(text); - if (parsed !== undefined) return parsed; - if (!text.includes(childSessionId)) return undefined; - if (/completed successfully/i.test(text)) { - return { childSessionId, status: "completed" as const }; - } - return undefined; - }, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-root-end-notice"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-carryover-root-end-notice"), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "user_message_chunk", - content: { - type: "text", - text: `Background subagent "${childSessionId}" (general-purpose: "background subagent") completed successfully.`, - }, - }, - }); - // Do not use Effect.timeout under TestClock; poll after yielding so a - // missed projection fails the assertion instead of hanging the suite. - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let completedSubagentTurnItems = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedSubagentTurnItems, - 0, - "root-session end notice must remain memory-only after interrupted settle", - ); - assert.lengthOf( - continuationRequests, - 0, - "root-session end notice must not open a continuation", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "hasPendingBackgroundWork must retain the unprojected end notice", - ); - - const attachNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: attachNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Attach deferred completion.", - }), - ); - const attachProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let attachTerminal: string | null = null; - while (attachTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - if (event.type === "turn.terminal" && event.providerTurnId === attachProviderTurnId) { - attachTerminal = event.status; - } - } - assert.equal(completedSubagentTurnItems, 1); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "preserves wakeBuffer when a child-session completes while a continuation is pending", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "mock-child-session-pending-continuation"; - const bufferedAssistantText = "POST_SETTLE_BUFFERED_ASSISTANT_TEXT"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-child-pending-continuation"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-carryover-child-pending-continuation", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: bufferedAssistantText }, - }, - }); - assert.lengthOf( - continuationRequests, - 1, - "root-session terminal must offer a continuation before child completion", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "pending continuation must keep hasPendingBackgroundWork pinned", - ); - - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let completedBeforeDrain = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedBeforeDrain += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedBeforeDrain, - 0, - "interrupted child-session terminal must wait for the continuation attach", - ); - assert.isTrue( - yield* hasPendingBackgroundWork, - "child-session must not clear the pin while wakeBuffer still has delivery", - ); - - const continuationNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: continuationNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }), - ); - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let continuationTerminal: string | null = null; - let completedSubagentTurnItems = 0; - let bufferedTextSeen = false; - while (continuationTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes(bufferedAssistantText) - ) { - bufferedTextSeen = true; - } - if ( - event.type === "turn.terminal" && - event.providerTurnId === continuationProviderTurnId - ) { - continuationTerminal = event.status; - } - } - assert.equal(continuationTerminal, "completed"); - assert.isTrue( - bufferedTextSeen, - "continuation drain must project buffered post-settle assistant text (buffer not wiped)", - ); - assert.equal( - completedSubagentTurnItems, - 1, - "continuation drain must project the terminal subagent turn item once", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "pin must clear after the continuation drains, not when the child session completed", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "projects once when root-session then child-session complete the same carryover subagent", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "mock-child-session-root-then-child"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-carryover-root-then-child"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-carryover-root-then-child", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "carryover live subagent must pin hasPendingBackgroundWork after root settle", - ); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - assert.lengthOf( - continuationRequests, - 1, - "root-session terminal must still offer a continuation", - ); - - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let completedBeforeDrain = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedBeforeDrain += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal( - completedBeforeDrain, - 0, - "interrupted root-then-child completion must defer to the continuation attach", - ); - - const continuationNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: continuationNow, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }), - ); - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let continuationTerminal: string | null = null; - let completedSubagentTurnItems = 0; - while (continuationTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedSubagentTurnItems += 1; - } - if ( - event.type === "turn.terminal" && - event.providerTurnId === continuationProviderTurnId - ) { - continuationTerminal = event.status; - } - } - assert.equal(continuationTerminal, "completed"); - assert.equal( - completedSubagentTurnItems, - 1, - "root-then-child completion must project the terminal subagent turn item exactly once", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "hasPendingBackgroundWork must end false after the continuation drains", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - let cancelCalled = false; - let runtimeOrdinalSeen = 0; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - restartRuntimeAfterInterrupt: true, - restartRuntimeOnEveryInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); - return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; - }, - wrapCancel: (cancel) => - Effect.sync(() => { - cancelCalled = true; - }).pipe(Effect.andThen(cancel)), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-settled-soft-steer"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - assert.isFalse( - cancelCalled, - "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", - ); - - let subagentTurnItemId: string | null = null; - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - subagentTurnItemId = event.turnItem.id; - } - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.notEqual(subagentTurnItemId, null); - - subagentPhase = "complete"; - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let carriedItemStatus: string | null = null; - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.id === subagentTurnItemId - ) { - carriedItemStatus = event.turnItem.status; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.equal(carriedItemStatus, "completed"); - assert.equal(secondTerminalStatus, "completed"); - assert.equal( - runtimeOrdinalSeen, - 1, - "settled soft steer must not respawn the ACP runtime process", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - restartRuntimeOnEveryInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-unsettled-steer-stays-hard", - ), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const interruptExit = yield* runtime - .interruptTurn({ providerThread, providerTurnId }) - .pipe(Effect.exit); - if (Exit.isSuccess(interruptExit)) { - assert.fail("mid-prompt steering interrupt must still take the hard teardown path"); - } - assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live( - "soft mid-prompt interrupt cancels in place, reuses the runtime, and tracks cancel-backgrounded work", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const instanceId = ProviderInstanceId.make("acp-test"); - let cancelCalled = false; - let runtimeOrdinalSeen = 0; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => - registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); - return { - T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT: "1", - T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL: "1", - }; - }, - protocolEvents, - wrapCancel: (cancel) => - Effect.sync(() => { - cancelCalled = true; - }).pipe(Effect.andThen(cancel)), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-soft-mid-prompt-steer"), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes("tool-call-running-1"), - ), - Stream.runHead, - ); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - assert.isTrue( - cancelCalled, - "soft mid-prompt interrupt must send session/cancel to detach the running work", - ); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - let backgroundTracked = false; - for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) { - backgroundTracked = yield* hasPendingBackgroundWork; - if (!backgroundTracked) { - yield* Effect.sleep("25 millis"); - } - } - assert.isTrue( - backgroundTracked, - "cancel-backgrounded task must be tracked as running background work", - ); - - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.equal(secondTerminalStatus, "completed"); - assert.equal( - runtimeOrdinalSeen, - 1, - "soft mid-prompt interrupt must not respawn the ACP runtime process", - ); - - let backgroundPending = true; - for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) { - backgroundPending = yield* hasPendingBackgroundWork; - if (backgroundPending) { - yield* Effect.sleep("100 millis"); - } - } - assert.isFalse( - backgroundPending, - "tracked background task must clear after _x.ai/task_completed", - ); - assert.lengthOf( - continuationRequests, - 0, - "a cancel-backgrounded task completion must not wake a synthetic continuation run", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - ownDetachedProcessGroup: true, - protocolEvents, - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-pre-stop", - status: "running", - }); - assert.isTrue( - yield* hasPendingBackgroundWork, - "a running background task mutation on the root session must track before Stop", - ); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-pre-stop", - status: "completed", - }); - assert.isFalse( - yield* hasPendingBackgroundWork, - "a completed background task mutation must clear pending background work", - ); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "interrupted"); - - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-late-after-stop", - status: "running", - }); - assert.isFalse( - yield* hasPendingBackgroundWork, - "direct Stop quarantine must drop residual background task mutations from the stopped run", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - let runtimeOrdinalSeen = 0; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => - registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); - return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; - }, - ownDetachedProcessGroup: true, - protocolEvents, - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-production-stop-hard-kill"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-production-stop-hard-kill"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "interrupted"); - - // Effect 4 Queue.takeAll waits for at least one element when empty. After - // the session/prompt stream drain the protocol queue is often empty, so - // takeAll would hang forever. - yield* Queue.clear(protocolEvents); - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ); - const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume", - ), - Stream.runHead, - ); - assert.isTrue( - Option.isSome(loadAfterRestart), - "user Stop must respawn the runtime and reload the session on the next turn", - ); - assert.equal( - runtimeOrdinalSeen, - 2, - "user Stop with production Grok flags must replace the ACP runtime process", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - // it.live: ownDetachedProcessGroup teardown uses wall-clock sleeps; under - // it.effect the interrupt timeout on context.completed still needs real time - // after the soft steer clears the turn. - it.live( - "Stop after settled soft steer contains the orphan runtime and respawns without subagent carryover", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - let cancelCalled = false; - let runtimeOrdinalSeen = 0; - const childSessionId = "mock-child-session-1"; - const authoritativeSubagentText = "authoritative subagent carryover text"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); - return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; - }, - ownDetachedProcessGroup: true, - protocolEvents, - wrapCancel: (cancel) => - Effect.sync(() => { - cancelCalled = true; - }).pipe(Effect.andThen(cancel)), - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-stop-after-soft-steer-orphan", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "agent_message", - messageId: "child-message-1", - content: [{ type: "text", text: authoritativeSubagentText }], - }, - }); - let subagentTurnItemId: string | null = null; - let authoritativeTextSeen = false; - for (let attempt = 0; attempt < 64; attempt += 1) { - const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); - if (Option.isNone(maybeEvent)) break; - const event = maybeEvent.value; - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - subagentTurnItemId = event.turnItem.id; - } - if ( - event.type === "message.updated" && - event.message.text.includes(authoritativeSubagentText) - ) { - authoritativeTextSeen = true; - break; - } - } - assert.isTrue( - authoritativeTextSeen, - "pre-steer child session upsert must project as subagent assistant text", - ); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - assert.isFalse( - cancelCalled, - "settled soft steer must not send session/cancel before the later Stop", - ); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - subagentTurnItemId = event.turnItem.id; - } - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.notEqual(subagentTurnItemId, null); - assert.equal( - runtimeOrdinalSeen, - 1, - "soft steer must leave the original ACP runtime process alive", - ); - - const stopExit = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.exit); - if (Exit.isFailure(stopExit)) { - assert.fail( - `cleared-turn Stop must contain the orphan runtime, not fail: ${Cause.pretty(stopExit.cause)}`, - ); - } - - let subagentStopStatus: string | null = null; - let subagentStopResult: string | null | undefined; - let subagentStopProviderThreadId: string | null | undefined; - let subagentUpdatedResult: string | null | undefined; - for (let attempt = 0; attempt < 64; attempt += 1) { - const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); - if (Option.isNone(maybeEvent)) break; - const event = maybeEvent.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.id === subagentTurnItemId - ) { - subagentStopStatus = event.turnItem.status; - subagentStopResult = event.turnItem.result; - subagentStopProviderThreadId = event.turnItem.providerThreadId; - if (subagentStopStatus === "interrupted") break; - } - if ( - event.type === "subagent.updated" && - event.subagent.status === "interrupted" && - event.subagent.result === authoritativeSubagentText - ) { - subagentUpdatedResult = event.subagent.result; - } - } - assert.equal( - subagentStopStatus, - "interrupted", - "orphan Stop must emit interrupted terminal for turn-1 carryover subagent", - ); - assert.equal( - subagentStopResult, - authoritativeSubagentText, - "orphan Stop must merge authoritative assistant text into the interrupted result", - ); - assert.equal( - subagentStopProviderThreadId, - providerThread.id, - "orphan Stop parent-level events must use the spawn-time parent provider thread id", - ); - assert.equal( - subagentUpdatedResult, - authoritativeSubagentText, - "orphan Stop subagent.updated must also carry the authoritative result", - ); - - yield* Queue.clear(protocolEvents); - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && - (rawProtocolMethod(event) === "session/resume" || - rawProtocolMethod(event) === "session/new"), - ), - Stream.runHead, - ); - assert.isTrue( - Option.isSome(loadAfterRestart), - "orphan containment must force a runtime respawn before the next turn", - ); - assert.equal( - runtimeOrdinalSeen, - 2, - "Stop after soft steer must replace the orphan ACP runtime process", - ); - - subagentPhase = "complete"; - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let carriedItemStatus: string | null = null; - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.id === subagentTurnItemId - ) { - carriedItemStatus = event.turnItem.status; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.isNull( - carriedItemStatus, - "Stop quarantine must drop turn-1 subagent carryover on the respawned runtime", - ); - assert.equal(secondTerminalStatus, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("Direct Stop projects an interrupt-deferred terminal exactly once", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const childSessionId = "mock-child-session-direct-stop-deferred-terminal"; - let subagentPhase: "spawn" | "complete" = "spawn"; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - ownDetachedProcessGroup: true, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { offer: () => Effect.void }, - }); - const threadId = ThreadId.make("thread-acp-direct-stop-deferred-terminal"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-direct-stop-deferred-terminal", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId }); - let rootTerminal: string | null = null; - while (rootTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - rootTerminal = event.status; - } - } - assert.equal(rootTerminal, "interrupted"); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - subagentPhase = "complete"; - yield* sessionUpdateHandler!({ - sessionId: childSessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-generic-1", - title: "background subagent", - kind: "other", - status: "completed", - rawOutput: { content: "SUB_DONE" }, - }, - }); - yield* Effect.yieldNow; - let completedBeforeStop = 0; - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - const event = polled.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedBeforeStop += 1; - } - polled = yield* Queue.poll(events); - } - assert.equal(completedBeforeStop, 0); - assert.isTrue( - yield* hasPendingBackgroundWork, - "interrupt-deferred terminal must pin until hard-stop projection", - ); - - const stopExit = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.exit); - if (Exit.isFailure(stopExit)) { - assert.fail(`Direct Stop must contain the orphan runtime: ${Cause.pretty(stopExit.cause)}`); - } - - let completedAfterStop = 0; - for (let attempt = 0; attempt < 64; attempt += 1) { - const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); - if (Option.isNone(maybeEvent)) break; - const event = maybeEvent.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - completedAfterStop += 1; - } - } - assert.equal(completedAfterStop, 1); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "settled soft interrupt skips cancel when prompt wire is settled before completion callback", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - let subagentPhase: "spawn" | "complete" = "spawn"; - let cancelCalled = false; - const promptPhases: Array = []; - const promptWireReturned = yield* Deferred.make(); - const releasePromptCompletion = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - preserveRuntimeOnSettledInterrupt: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId !== "tool-call-generic-1" - ? undefined - : subagentPhase === "spawn" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : { - nativeTaskId: "task-generic-1", - prompt: "", - title: null, - model: null, - status: "completed", - childSessionId: null, - result: "SUB_DONE", - }, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - protocolEvents, - wrapCancel: (cancel) => - Effect.sync(() => { - cancelCalled = true; - }).pipe(Effect.andThen(cancel)), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: (payload) => - Effect.gen(function* () { - promptPhases.push("prompt-start"); - const result = yield* runtime.prompt(payload); - promptPhases.push("wire-returned"); - yield* Deferred.succeed(promptWireReturned, undefined); - yield* Deferred.await(releasePromptCompletion); - promptPhases.push("completion-released"); - return result; - }), - }), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-settled-soft-admission-race", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptWireReturned); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"stopReason"'), - ), - Stream.runHead, - ); - assert.deepEqual(promptPhases, ["prompt-start", "wire-returned"]); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) - .pipe(Effect.forkScoped); - yield* Deferred.succeed(releasePromptCompletion, undefined); - for (let attempt = 0; attempt < 20; attempt += 1) { - if (promptPhases.includes("completion-released")) break; - yield* Effect.yieldNow; - } - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - - assert.includeMembers(promptPhases, [ - "prompt-start", - "wire-returned", - "completion-released", - ]); - assert.isFalse( - cancelCalled, - "settled soft steer must skip session/cancel once the prompt wire has settled", - ); - - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.equal(subagentPhase, "spawn"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, - extractBackgroundTaskCompletion: (toolCall) => - toolCall.toolCallId === "tool-call-fetch-1" - ? [ - { - taskId: "task-monitor-1", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", - }, - ] - : [], - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { - T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1", - }, - protocolEvents, - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-already-handled-wake-pin"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-already-handled-wake-pin", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes("MONITOR_LISTING_TOKEN_LATE"), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - assert.lengthOf( - continuationRequests, - 0, - "already-handled late TaskOutput must not open a continuation run", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "wake buffer must not stay non-empty and pin idle release after an already-handled re-report", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "keeps a dispatched continuation offer sticky until a turn starts or the worker drops it", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-sticky-continuation-dispatch"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-sticky-continuation-dispatch", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - const lateTool = (toolCallId: string) => - sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId, - title: "Late tool result", - kind: "other", - status: "completed", - rawOutput: { output: toolCallId }, - }, - }); - yield* lateTool("first-late-result"); - assert.lengthOf(continuationRequests, 1); - const first = continuationRequests[0]!; - assert.isDefined(first.dispatchIfCurrent); - assert.isTrue(Option.isSome(yield* first.dispatchIfCurrent!(Effect.void))); - - yield* lateTool("second-frame-before-dispatched-turn-starts"); - assert.lengthOf( - continuationRequests, - 1, - "late frames must not enqueue duplicate continuations during dispatch-to-start", - ); - - assert.isDefined(first.clearIfCurrent); - yield* first.clearIfCurrent!(); - yield* lateTool("new-result-after-worker-drop"); - assert.lengthOf(continuationRequests, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("keeps a buffered continuation current when a user turn starts before dispatch", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - const userPromptStarted = yield* Deferred.make(); - const releaseUserPrompt = yield* Deferred.make(); - const bufferedAssistantText = "BUFFERED_WAKE_QUEUED_AFTER_USER"; - let promptOrdinal = 0; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: (payload) => - Effect.gen(function* () { - promptOrdinal += 1; - const currentPromptOrdinal = promptOrdinal; - const result = yield* runtime.prompt(payload); - if (currentPromptOrdinal === 2) { - yield* Deferred.succeed(userPromptStarted, undefined); - yield* Deferred.await(releaseUserPrompt); - } - return result; - }), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-buffered-continuation-user-race"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-buffered-continuation-user-race", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "completed"); - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: bufferedAssistantText }, - }, - }); - assert.lengthOf(continuationRequests, 1); - const continuationRequest = continuationRequests[0]!; - assert.isDefined(continuationRequest.dispatchIfCurrent); - - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - messageText: "User turn won the continuation dispatch race.", - }), - ); - yield* Deferred.await(userPromptStarted); - let continuationDispatched = false; - const dispatchOutcome = yield* continuationRequest.dispatchIfCurrent!( - Effect.sync(() => { - continuationDispatched = true; - }), - ); - assert.isTrue( - Option.isSome(dispatchOutcome), - "queue_after_active dispatch must remain current while the user run is active", - ); - assert.isTrue(continuationDispatched); - yield* Deferred.succeed(releaseUserPrompt, undefined); - - const userProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let userTerminalStatus: string | null = null; - while (userTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) { - userTerminalStatus = event.status; - } - } - assert.equal(userTerminalStatus, "completed"); - assert.isTrue( - yield* hasPendingBackgroundWork, - "the queued continuation must retain ownership of its buffered wake traffic", - ); - - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }), - ); - yield* TestClock.adjust("3 seconds"); - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"), - }); - let continuationTerminalStatus: string | null = null; - let bufferedTextSeen = false; - while (continuationTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes(bufferedAssistantText) - ) { - bufferedTextSeen = true; - } - if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) { - continuationTerminalStatus = event.status; - } - } - assert.equal(continuationTerminalStatus, "completed"); - assert.isTrue(bufferedTextSeen, "continuation must drain the wake buffer after the user run"); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "holds a settled turn until the injected monitor report streams instead of finalizing into it", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const triggerDir = yield* fileSystem.makeTempDirectoryScoped(); - const triggerPath = path.join(triggerDir, "report-trigger"); - const protocolEvents = yield* Queue.bounded(256); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, - extractBackgroundToolMutation: (text) => - text.includes('Monitor "task-monitor-1" ended') - ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }] - : [], - extractBackgroundTaskCompletion: (toolCall) => - toolCall.toolCallId === "tool-call-fetch-1" - ? [ - { - taskId: "task-monitor-1", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", - }, - ] - : [], - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { - T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1", - T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath, - }, - protocolEvents, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-injected-report-hold"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-injected-report"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - - let reportSeen = false; - const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text.includes("MONITOR_REPORT_TOKEN") - ) { - reportSeen = true; - } - }; - let hydrated = false; - while (!hydrated) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN") - ) { - hydrated = true; - } - } - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - let drained = yield* Queue.poll(events); - while (Option.isSome(drained)) { - trackReport(drained.value); - assert.notEqual( - drained.value.type, - "turn.terminal", - "deferred finalize must hold while the injected-turn report is owed", - ); - drained = yield* Queue.poll(events); - } - - yield* fileSystem.writeFileString(triggerPath, "go"); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes("MONITOR_REPORT_TOKEN"), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* TestClock.adjust("3 seconds"); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - trackReport(event); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - assert.isTrue( - reportSeen, - "the injected-turn report must project before the turn finalizes", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "settle-without-report held turn with mid-hold ext completion does not open a wake after the injected report", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const triggerDir = yield* fileSystem.makeTempDirectoryScoped(); - const triggerPath = path.join(triggerDir, "report-trigger"); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, - extractBackgroundToolMutation: (text) => - text.includes('Monitor "task-monitor-1" ended') - ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }] - : [], - extractBackgroundTaskCompletion: (toolCall) => - toolCall.toolCallId === "tool-call-fetch-1" - ? [ - { - taskId: "task-monitor-1", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", - }, - ] - : [], - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { - T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1", - T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath, - }, - protocolEvents, - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-settle-hold-ext-complete-no-wake"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-settle-hold-ext-complete-no-wake", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - - let reportSeen = false; - const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text.includes("MONITOR_REPORT_TOKEN") - ) { - reportSeen = true; - } - }; - let hydrated = false; - while (!hydrated) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN") - ) { - hydrated = true; - } - } - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-1", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "ext completion during the settled-held window must not offer mid-hold", - ); - - yield* TestClock.adjust("3 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - let drained = yield* Queue.poll(events); - while (Option.isSome(drained)) { - trackReport(drained.value); - assert.notEqual( - drained.value.type, - "turn.terminal", - "deferred finalize must hold while the injected-turn report is owed", - ); - drained = yield* Queue.poll(events); - } - - yield* fileSystem.writeFileString(triggerPath, "go"); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes("MONITOR_REPORT_TOKEN"), - ), - Stream.runHead, - ); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* TestClock.adjust("3 seconds"); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - trackReport(event); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - assert.isTrue( - reportSeen, - "the injected-turn report must project before the turn finalizes", - ); - assert.lengthOf( - continuationRequests, - 0, - "settle-without-report with mid-hold ext completion must not open a wake after the report streams", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "pre-settle ext completion arm is cleared when the report precedes its end notice", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, - extractBackgroundToolMutation: (text) => - text.includes('Monitor "task-monitor-1" ended') - ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }] - : [], - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-pre-settle-arm-cleared-by-report"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-pre-settle-arm-cleared-by-report", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-1", - title: "Monitor: pre-settle complete", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-1", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-1", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-1", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "pre-settle unhandled completion must not offer while the turn is active", - ); - - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.lengthOf( - continuationRequests, - 0, - "settle must hold for deferred background work; midTurn alone must not offer mid-hold", - ); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Monitor finished. MONITOR_REPORT_TOKEN" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "user_message_chunk", - content: { - type: "text", - text: 'Monitor "task-monitor-1" ended: [monitor ended: exit 0]', - }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - let reportSeen = false; - const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text.includes("MONITOR_REPORT_TOKEN") - ) { - reportSeen = true; - } - if ( - event.type === "message.updated" && - event.message.text.includes("MONITOR_REPORT_TOKEN") - ) { - reportSeen = true; - } - }; - let drained = yield* Queue.poll(events); - while (Option.isSome(drained)) { - trackReport(drained.value); - assert.notEqual( - drained.value.type, - "turn.terminal", - "turn must stay open while hydration hold / quiet window remain", - ); - drained = yield* Queue.poll(events); - } - assert.isTrue(reportSeen, "injected report must project into the held turn"); - - yield* TestClock.adjust("60 seconds"); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* TestClock.adjust("3 seconds"); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - trackReport(event); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - assert.lengthOf( - continuationRequests, - 0, - "pre-settle arm must be cleared when the injected report streams; no duplicate wake", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "staggered pre-settle completion keeps midTurn marks until last background task ends post-finalize", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => { - if (toolCall.toolCallId === "tool-call-monitor-a") return "task-monitor-a"; - if (toolCall.toolCallId === "tool-call-monitor-b") return "task-monitor-b"; - return undefined; - }, - extractBackgroundToolMutation: (text) => { - if (text.includes('Monitor "task-monitor-a" ended')) { - return [{ taskId: "task-monitor-a", status: "completed", appendOutput: "" }]; - } - if (text.includes('Monitor "task-monitor-b" ended')) { - return [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }]; - } - return []; - }, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-staggered-midturn-keep-until-last"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-staggered-midturn-keep-until-last", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - for (const [toolCallId, title] of [ - ["tool-call-monitor-a", "Monitor: staggered A"], - ["tool-call-monitor-b", "Monitor: staggered B"], - ] as const) { - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId, - title, - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId, - status: "in_progress", - }, - }); - } - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-a", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-b", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-a", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "pre-settle unhandled completion must not offer while the turn is active", - ); - - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "completed"); - assert.lengthOf( - continuationRequests, - 0, - "finalize must not offer while B is still running", - ); - - const bEndNotice = { - sessionId: "mock-session-1", - update: { - sessionUpdate: "user_message_chunk" as const, - content: { - type: "text" as const, - text: 'Monitor "task-monitor-b" ended: [monitor ended: exit 0]', - }, - }, - }; - assert.isFalse( - acpPostSettleWakeEvidence(bEndNotice, { - extractBackgroundToolMutation: (text) => - text.includes('Monitor "task-monitor-b" ended') - ? [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }] - : [], - }), - "mutation-only end-notice must not count as post-settle wake evidence", - ); - yield* sessionUpdateHandler!(bEndNotice); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.lengthOf( - continuationRequests, - 1, - "exactly one continuation when the last running task ends post-finalize with kept midTurn marks", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "staggered pre-settle completion does not keep midTurn marks when the turn is interrupted", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => { - if (toolCall.toolCallId === "tool-call-monitor-a") return "task-monitor-a"; - if (toolCall.toolCallId === "tool-call-monitor-b") return "task-monitor-b"; - return undefined; - }, - extractBackgroundToolMutation: (text) => { - if (text.includes('Monitor "task-monitor-a" ended')) { - return [{ taskId: "task-monitor-a", status: "completed", appendOutput: "" }]; - } - if (text.includes('Monitor "task-monitor-b" ended')) { - return [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }]; - } - return []; - }, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-staggered-midturn-clear-on-interrupt"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-staggered-midturn-clear-on-interrupt", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - for (const [toolCallId, title] of [ - ["tool-call-monitor-a", "Monitor: staggered interrupt A"], - ["tool-call-monitor-b", "Monitor: staggered interrupt B"], - ] as const) { - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId, - title, - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId, - status: "in_progress", - }, - }); - } - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-a", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-b", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-a", - status: "completed", - }); - assert.lengthOf(continuationRequests, 0); - - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.lengthOf( - continuationRequests, - 0, - "settle must hold for deferred background tools; no mid-hold offer", - ); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId }); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "interrupted"); - assert.lengthOf( - continuationRequests, - 0, - "interrupted finalize must not offer a continuation", - ); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "user_message_chunk", - content: { - type: "text", - text: 'Monitor "task-monitor-b" ended: [monitor ended: exit 0]', - }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.lengthOf( - continuationRequests, - 0, - "interrupted turns must clear midTurn marks; B ending post-finalize must not offer", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect( - "two-turn in-turn monitors never open a wake run or retain turn-1 injected-turn ack chatter", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGates: Array> = []; - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => { - if (toolCall.toolCallId === "tool-call-monitor-1") return "task-monitor-1"; - if (toolCall.toolCallId === "tool-call-monitor-2") return "task-monitor-2"; - return undefined; - }, - extractBackgroundToolMutation: (text) => { - if (text.includes('Monitor "task-monitor-1" ended')) { - return [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]; - } - if (text.includes('Monitor "task-monitor-2" ended')) { - return [{ taskId: "task-monitor-2", status: "completed", appendOutput: "" }]; - } - return []; - }, - extractBackgroundTaskCompletion: (toolCall) => { - if (toolCall.toolCallId === "tool-call-fetch-1") { - return [ - { - taskId: "task-monitor-1", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", - }, - ]; - } - if (toolCall.toolCallId === "tool-call-fetch-2") { - return [ - { - taskId: "task-monitor-2", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN_2" : "", - }, - ]; - } - return []; - }, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - promptGates.push(gate); - return yield* Deferred.await(gate); - }), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-multiturn-in-turn-monitor-no-wake"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-multiturn-in-turn-monitor-no-wake", - ), - modelSelection, - runtimePolicy, - }); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - while (promptGates.length < 1) { - yield* Effect.yieldNow; - } - assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-1", - title: "Monitor: first turn", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-1", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-1", - status: "running", - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-fetch-1", - title: "get_command_or_subagent_output", - kind: "other", - status: "pending", - rawInput: { task_id: "task-monitor-1" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-fetch-1", - status: "completed", - rawOutput: { output: "MONITOR_LISTING_TOKEN" }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Monitor listing ready in-turn." }, - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-1", - status: "completed", - }); - assert.lengthOf(continuationRequests, 0); - - yield* Deferred.succeed(promptGates[0]!, { stopReason: "end_turn" }); - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let firstTerminal: string | null = null; - while (firstTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminal = event.status; - } - } - assert.equal(firstTerminal, "completed"); - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "user_message_chunk", - content: { - type: "text", - text: 'Monitor "task-monitor-1" ended', - }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_thought_chunk", - content: { - type: "text", - text: "That monitor event is the same run I already summarized.", - }, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "That monitor event is the same run I already summarized above.", - }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.lengthOf( - continuationRequests, - 0, - "turn-1 injected-turn acks must not open a continuation", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "turn-1 injected-turn ack chatter must not remain as wake buffer evidence", - ); - - yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - }), - ) - .pipe(Effect.forkScoped); - while (promptGates.length < 2) { - yield* Effect.yieldNow; - } - if (sessionUpdateHandler === undefined || capturedMutation.current === null) { - return yield* Effect.die("extensions and session handler must be wired for turn 2"); - } - - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-2", - title: "Monitor: second turn", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-2", - status: "in_progress", - }, - }); - yield* capturedMutation.current({ - sessionId: "mock-session-1", - taskId: "task-monitor-2", - status: "running", - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-fetch-2", - title: "get_command_or_subagent_output", - kind: "other", - status: "pending", - rawInput: { task_id: "task-monitor-2" }, - }, - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-fetch-2", - status: "completed", - rawOutput: { output: "MONITOR_LISTING_TOKEN_2" }, - }, - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Second monitor listing ready in-turn." }, - }, - }); - yield* capturedMutation.current({ - sessionId: "mock-session-1", - taskId: "task-monitor-2", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "mid-turn task_completed for an in-turn-handled monitor must not open a wake run", - ); - - yield* Deferred.succeed(promptGates[1]!, { stopReason: "end_turn" }); - const secondProviderTurnId = idAllocator.derive.providerTurn({ + ); + const secondProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), }); - let secondTerminal: string | null = null; - while (secondTerminal === null) { + let carriedItemStatus: string | null = null; + let secondTerminalStatus: string | null = null; + while (secondTerminalStatus === null) { const event = yield* Queue.take(events); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.id === subagentTurnItemId + ) { + carriedItemStatus = event.turnItem.status; + } if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminal = event.status; + secondTerminalStatus = event.status; } } - assert.equal(secondTerminal, "completed"); - assert.lengthOf( - continuationRequests, - 0, - "no continuation must be offered across the multiturn in-turn monitor sequence", - ); - assert.isFalse( - yield* hasPendingBackgroundWork, - "wake buffer must not retain turn-1 or turn-2 in-turn-handled ack residue", - ); + assert.equal(carriedItemStatus, "completed"); + assert.equal(secondTerminalStatus, "completed"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("a wake names work that ended while the previous wake was queued", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - let applyMutation: AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined; - const promptGate = yield* Deferred.make(); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - deferFinalizeForBackgroundWork: true, - registerExtensions: (context) => - Effect.sync(() => { - applyMutation = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), + it.effect( + "projects a child terminal in the completed-root finalization window exactly once", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const baseClock = yield* Clock.Clock; + const finalizationClockRead = yield* Deferred.make(); + const releaseFinalizationClockRead = yield* Deferred.make(); + let blockNextClockRead = false; + const blockingClock: Clock.Clock = { + ...baseClock, + currentTimeMillis: Effect.suspend(() => { + if (!blockNextClockRead) return baseClock.currentTimeMillis; + blockNextClockRead = false; + return Deferred.succeed(finalizationClockRead, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizationClockRead)), + Effect.andThen(baseClock.currentTimeMillis), + ); }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-wake-report-while-queued"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-wake-report-while-queued"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const awaitTerminal = (ordinal: number) => - Effect.gen(function* () { + }; + yield* Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("acp-test"); + const firstChildSessionId = "mock-child-session-finalize-window-first"; + const secondChildSessionId = "mock-child-session-finalize-window-second"; + let firstSubagentStatus: "running" | "completed" = "running"; + let secondSubagentStatus: "waiting" | "completed" = "waiting"; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: + | Parameters[0] + | undefined; + const promptSettled = yield* Deferred.make(); + const adapter = yield* makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, + extractSubagentUpdate: (toolCall) => { + if (toolCall.toolCallId === "tool-call-generic-1") { + return { + nativeTaskId: "task-finalize-window-first", + prompt: "first background subagent", + title: "first background subagent", + model: null, + status: firstSubagentStatus, + childSessionId: firstChildSessionId, + result: firstSubagentStatus === "completed" ? "FIRST_DONE" : null, + suppressNormalTool: true, + }; + } + if (toolCall.toolCallId === "tool-call-generic-2") { + return { + nativeTaskId: "task-finalize-window-second", + prompt: "second background subagent", + title: "second background subagent", + model: null, + status: secondSubagentStatus, + childSessionId: secondChildSessionId, + result: secondSubagentStatus === "completed" ? "SECOND_DONE" : null, + suppressNormalTool: true, + } as AcpAdapterV2SubagentUpdate; + } + return undefined; + }, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + }), + }), + }, + selfInvocation, + continuationRequests: { offer: () => Effect.void }, + }); + const threadId = ThreadId.make("thread-acp-subagent-finalize-window"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-subagent-finalize-window", + ), + modelSelection, + runtimePolicy, + }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Deferred.await(promptSettled); + yield* TestClock.adjust("1 second"); + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); + + firstSubagentStatus = "completed"; + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-generic-1", + title: "first background subagent", + kind: "other", + status: "completed", + rawOutput: { content: "FIRST_DONE" }, + }, + }).pipe(Effect.provideService(Clock.Clock, blockingClock)); + for (let attempt = 0; attempt < 10; attempt += 1) { + yield* Effect.yieldNow; + } + + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-generic-2", + title: "second background subagent", + kind: "other", + status: "in_progress", + rawOutput: {}, + }, + }); + assert.isTrue(yield* hasPendingBackgroundWork); + + while (Option.isSome(yield* Queue.poll(events))) {} + + blockNextClockRead = true; + const adjustFiber = yield* TestClock.adjust("3 seconds").pipe(Effect.forkScoped); + yield* Deferred.await(finalizationClockRead); + secondSubagentStatus = "completed"; + yield* sessionUpdateHandler!({ + sessionId: secondChildSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-generic-2", + title: "second background subagent", + kind: "other", + status: "completed", + rawOutput: { content: "SECOND_DONE" }, + }, + }); + yield* Deferred.succeed(releaseFinalizationClockRead, undefined); + yield* Fiber.join(adjustFiber); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, `mock-session-1:turn:${ordinal}`), + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - while (true) { - const event = yield* Queue.take(events); + let completedSubagentUpdates = 0; + let rootTerminalStatus: string | null = null; + let polled = yield* Queue.poll(events); + while (Option.isSome(polled)) { + const event = polled.value; + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === "task-finalize-window-second" && + event.turnItem.status === "completed" + ) { + completedSubagentUpdates += 1; + } if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - return event.status; + rootTerminalStatus = event.status; } + polled = yield* Queue.poll(events); } - }); - yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - assert.equal(yield* awaitTerminal(1), "completed"); - if (applyMutation === undefined || sessionUpdateHandler === undefined) { - return yield* Effect.die("extensions and session handler must be wired"); - } - const endCommand = (taskId: string, label: string) => - applyMutation!({ - sessionId: "mock-session-1", - taskId, - status: "completed", - report: { kind: "command", label }, - }); - const agentText = (text: string) => - sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, - }); - - yield* endCommand("task-a", "sleep a"); - yield* agentText("A_DONE"); - assert.lengthOf(continuationRequests, 1); - assert.equal(continuationRequests[0]?.notification?.summary, 'Command "sleep a" finished'); - yield* endCommand("task-b", "sleep b"); - assert.lengthOf(continuationRequests, 1); - - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - messageText: "Background task completed.", - }), - ); - yield* TestClock.adjust("3 seconds"); - assert.equal(yield* awaitTerminal(2), "completed"); - - yield* agentText("B_DONE"); - assert.lengthOf(continuationRequests, 2); - assert.deepEqual(continuationRequests[1]?.notification, { - source: { kind: "command" }, - outcome: "completed", - summary: 'Command "sleep b" finished', - }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + assert.equal(rootTerminalStatus, "completed"); + assert.equal(completedSubagentUpdates, 1); + assert.isFalse( + yield* hasPendingBackgroundWork, + "finalize-window terminal must clear the carryover pin", + ); + }).pipe(Effect.provideService(Clock.Clock, blockingClock)); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("mid-turn completed mutation defers offer until finalize only when unhandled", () => + it.effect("projects completed-root carryover eagerly and drain cannot resurrect it", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -11833,49 +4513,45 @@ describe("AcpAdapterV2", () => { const protocolEvents = yield* Queue.bounded(256); const continuationRequests: Array = []; + const instanceId = ProviderInstanceId.make("acp-test"); + const childSessionId = "019f44a6-4820-7402-925d-bc862ee711dd"; + let subagentPhase: "spawn" | "complete" = "spawn"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: Parameters[0] | undefined; - const promptGates: Array> = []; - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-unhandled" - ? "task-monitor-unhandled" - : toolCall.toolCallId === "tool-call-monitor-handled" - ? "task-monitor-handled" - : undefined, - extractBackgroundTaskCompletion: (toolCall) => - toolCall.toolCallId === "tool-call-fetch-handled" - ? [ - { - taskId: "task-monitor-handled", - status: toolCall.status === "completed" ? "completed" : "running", - appendOutput: toolCall.status === "completed" ? "HANDLED_LISTING" : "", - }, - ] - : [], - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), + normalizeToolCall: normalizeXAiAcpToolCallState, + extractSubagentUpdate: (toolCall) => + extractXAiAcpSubagentUpdate(toolCall) ?? + (toolCall.toolCallId !== "tool-call-generic-1" + ? undefined + : subagentPhase === "spawn" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId, + result: null, + } + : { + nativeTaskId: "task-generic-1", + prompt: "", + title: null, + model: null, + status: "completed", + childSessionId, + result: "SUB_DONE", + }), makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, protocolEvents, wrapRuntime: (runtime) => ({ ...runtime, @@ -11883,12 +4559,6 @@ describe("AcpAdapterV2", () => { Effect.sync(() => { sessionUpdateHandler = handler; }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - promptGates.push(gate); - return yield* Deferred.await(gate); - }), }), }), }, @@ -11900,7 +4570,7 @@ describe("AcpAdapterV2", () => { }), }, }); - const threadId = ThreadId.make("thread-acp-mid-turn-dirty-wake-buffer"); + const threadId = ThreadId.make("thread-acp-completed-root-eager-carryover"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -11910,7 +4580,7 @@ describe("AcpAdapterV2", () => { const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make( - "provider-session-acp-mid-turn-dirty-wake-buffer", + "provider-session-acp-completed-root-eager-carryover", ), modelSelection, runtimePolicy, @@ -11932,479 +4602,221 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - while (promptGates.length < 1) { - yield* Effect.yieldNow; - } - yield* Deferred.succeed(promptGates[0]!, { stopReason: "end_turn" }); - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let firstTerminal: string | null = null; - while (firstTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminal = event.status; - } - } - assert.equal(firstTerminal, "completed"); - assert.isDefined(sessionUpdateHandler); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "stale wake residue from prior turn" }, - }, - }); - assert.isTrue(yield* hasPendingBackgroundWork, "thought residue must dirty the wake buffer"); - assert.lengthOf(continuationRequests, 0); - - yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - }), - ) - .pipe(Effect.forkScoped); - while (promptGates.length < 2) { - yield* Effect.yieldNow; - } - assert.isFalse( - yield* hasPendingBackgroundWork, - "user-turn start must drop prior-turn wake residue", + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - const applyMutation = capturedMutation.current; - if (applyMutation === null || sessionUpdateHandler === undefined) { - return yield* Effect.die("extensions and session handler must be wired for turn 2"); - } - - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-unhandled", - title: "Monitor: unhandled", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-unhandled", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-unhandled", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-unhandled", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "mid-turn completed mutation must not offer while the root turn is active", + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "incoming" && + event.stage === "raw" && + typeof event.payload === "string" && + event.payload.includes('"stopReason"'), + ), + Stream.runHead, ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* TestClock.adjust("1 second"); - yield* Deferred.succeed(promptGates[1]!, { stopReason: "end_turn" }); - const secondProviderTurnId = idAllocator.derive.providerTurn({ + const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - let secondTerminal: string | null = null; - while (secondTerminal === null) { + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminal = event.status; + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; } } - assert.equal(secondTerminal, "completed"); - assert.lengthOf( - continuationRequests, - 1, - "post-finalize must offer when mid-turn completion was never handled in-turn", - ); + assert.equal(firstTerminalStatus, "completed"); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - const firstOffer = continuationRequests[0]!; - if (firstOffer.clearIfCurrent !== undefined) { - yield* firstOffer.clearIfCurrent(); - } - continuationRequests.length = 0; - yield* runtime - .startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 3, - }), - ) - .pipe(Effect.forkScoped); - while (promptGates.length < 3) { - yield* Effect.yieldNow; - } - if (sessionUpdateHandler === undefined || capturedMutation.current === null) { - return yield* Effect.die("handler must remain wired for turn 3"); - } - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-handled", - title: "Monitor: handled", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-handled", - status: "in_progress", - }, - }); - yield* capturedMutation.current({ - sessionId: "mock-session-1", - taskId: "task-monitor-handled", - status: "running", - }); - yield* sessionUpdateHandler({ + // A production Grok spawn ACK has raw tool status completed but extracts + // as a running subagent with its original non-empty prompt. + yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { sessionUpdate: "tool_call", - toolCallId: "tool-call-fetch-handled", - title: "get_command_or_subagent_output", + toolCallId: "tool-call-generic-1", + title: "spawn_subagent", kind: "other", - status: "pending", - rawInput: {}, + status: "completed", + rawInput: { + description: "background subagent", + prompt: "background subagent", + subagent_type: "general-purpose", + }, + rawOutput: { + type: "Text", + text: [ + "Subagent started in background.", + `subagent_id: ${childSessionId}`, + "type: general-purpose", + "description: background subagent", + "", + `Use get_command_or_subagent_output with task_ids=["${childSessionId}"] and timeout_ms to wait for results.`, + ].join("\n"), + }, }, }); - yield* sessionUpdateHandler({ - sessionId: "mock-session-1", + + subagentPhase = "complete"; + yield* sessionUpdateHandler!({ + sessionId: childSessionId, update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-fetch-handled", - status: "completed", - rawOutput: { output: "HANDLED_LISTING" }, + sessionUpdate: "tool_call_update" as const, + toolCallId: "tool-call-generic-1", + title: "background subagent", + kind: "other" as const, + status: "completed" as const, + rawOutput: { content: "SUB_DONE" }, }, }); - yield* capturedMutation.current({ - sessionId: "mock-session-1", - taskId: "task-monitor-handled", - status: "completed", - }); - assert.lengthOf(continuationRequests, 0, "handled mid-turn completion must not offer"); - yield* Deferred.succeed(promptGates[2]!, { stopReason: "end_turn" }); - const thirdProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"), - }); - let thirdTerminal: string | null = null; - while (thirdTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === thirdProviderTurnId) { - thirdTerminal = event.status; - } - } - assert.equal(thirdTerminal, "completed"); - assert.lengthOf( - continuationRequests, - 0, - "post-finalize must not offer when the mid-turn completion was handled in-turn", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; - it.effect( - "mid-turn unhandled background completion still offers exactly one continuation after finalize", - () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-mid-turn-unhandled-offers-after-finalize"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-mid-turn-unhandled-offers-after-finalize", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); + let eagerCompletedUpdates = 0; + let eagerRunningUpdates = 0; + let polled = yield* Queue.poll(events); + while (Option.isSome(polled)) { + const event = polled.value; + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { + if (event.turnItem.status === "completed") eagerCompletedUpdates += 1; + if (event.turnItem.status === "running") eagerRunningUpdates += 1; } + polled = yield* Queue.poll(events); + } + assert.equal(eagerCompletedUpdates, 1, "completed root must project before any attach"); + assert.equal(eagerRunningUpdates, 0); + assert.lengthOf(continuationRequests, 1); + assert.isTrue(yield* hasPendingBackgroundWork, "buffered spawn ACK still requires a drain"); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-late", - title: "Monitor: late complete", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-late", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "completed", - }); - assert.lengthOf( - continuationRequests, - 0, - "mid-turn unhandled completion must not offer while the turn is active", - ); - - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } + const continuationNow = yield* DateTime.now; + const continuationInput = makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: continuationNow, + ordinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", + }); + yield* runtime.startTurn(continuationInput); + yield* Effect.yieldNow; + let replayedLineages = 0; + let replayedSubagentUpdates = 0; + let replayedTurnItems = 0; + polled = yield* Queue.poll(events); + while (Option.isSome(polled)) { + const event = polled.value; + if (event.type === "app_thread.created") { + replayedLineages += 1; } - assert.equal(terminalStatus, "completed"); - assert.lengthOf( - continuationRequests, - 1, - "exactly one continuation must be offered after finalize for mid-turn unhandled completion", - ); - const beforeWhitespace = yield* runtime.readThreadSnapshot({ providerThread }); - yield* Queue.clear(events); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: " \n\t " }, - }, - }); - const lateEvents: Array = []; - let lateEvent = yield* Queue.poll(events); - while (Option.isSome(lateEvent)) { - lateEvents.push(lateEvent.value); - lateEvent = yield* Queue.poll(events); + if (event.type === "subagent.updated" && event.subagent.runId === continuationInput.runId) { + replayedSubagentUpdates += 1; } - assert.isFalse( - lateEvents.some((event) => event.type === "message.updated"), - "whitespace-only late chunks must not append assistant history", - ); - const afterWhitespace = yield* runtime.readThreadSnapshot({ providerThread }); - assert.deepEqual( - afterWhitespace.messages, - beforeWhitespace.messages, - "whitespace-only late chunks must not mutate the loaded history snapshot", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + if ( + event.type === "turn_item.updated" && + event.turnItem.runId === continuationInput.runId + ) { + replayedTurnItems += 1; + } + polled = yield* Queue.poll(events); + } + assert.equal(replayedLineages, 0, "buffered spawn ACK must not create a second lineage"); + assert.equal( + replayedSubagentUpdates, + 0, + "buffered spawn ACK must not re-open the terminal subagent", + ); + assert.equal( + replayedTurnItems, + 0, + "buffered spawn ACK must not create a continuation-owned turn item", + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect.each([ - { status: "completed" as const, exitCode: 0 }, - { status: "failed" as const, exitCode: 1 }, - ])( - "retains early Grok $status command completion across sparse notifications and replayed ACKs", - ({ status, exitCode }) => + it.effect( + "user attach flushes a deferred terminal but leaves wake traffic for its continuation", + () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - let taskCompleted: - | ((payload: unknown) => Effect.Effect) - | undefined; - const promptStarted = yield* Deferred.make(); - const promptGate = yield* Deferred.make(); - const promptHeld = yield* Deferred.make(); - const finalizeScheduled = yield* Deferred.make(); - const continuationOffered = yield* Deferred.make(); const continuationRequests: Array = []; + const bufferedAssistantText = "BUFFERED_WAKE_AFTER_USER_ATTACH"; const instanceId = ProviderInstanceId.make("acp-test"); - const grokFlavor = makeGrokAcpAdapterFlavor( - { - instanceId, - settings: DEFAULT_GROK_SETTINGS, - environment: {}, - hostPlatform: yield* HostProcess.Platform, - selfInvocation, - }, - yield* HostProcess.HomeDirectory, - ); + let subagentPhase: "spawn" | "complete" = "spawn"; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + const promptSettled = yield* Deferred.make(); const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation, testHooks: { afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptHeld, undefined).pipe(Effect.asVoid), - onDeferredFinalizeScheduled: (debounce) => - Deferred.succeed(finalizeScheduled, debounce).pipe(Effect.asVoid), + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), }, + instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, deferFinalizeForBackgroundWork: true, - normalizeToolCall: grokFlavor.normalizeToolCall!, - extractBackgroundTaskId: grokFlavor.extractBackgroundTaskId!, - isBackgroundCommandTool: grokFlavor.isBackgroundCommandTool!, - registerExtensions: ({ runtime, applyBackgroundTaskMutation }) => - registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation), + enablePostSettleContinuation: true, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId !== "tool-call-generic-1" + ? undefined + : subagentPhase === "spawn" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId: null, + result: null, + } + : { + nativeTaskId: "task-generic-1", + prompt: "", + title: null, + model: null, + status: "completed", + childSessionId: null, + result: "SUB_DONE", + }, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, wrapRuntime: (runtime) => ({ ...runtime, handleSessionUpdate: (handler) => Effect.sync(() => { sessionUpdateHandler = handler; }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - handleExtNotification: (method, schema, handler) => - Effect.sync(() => { - if (method === "x.ai/task_completed") { - taskCompleted = (payload) => - handler(Schema.decodeUnknownSync(schema)(payload)); - } - }).pipe(Effect.andThen(runtime.handleExtNotification(method, schema, handler))), - prompt: () => - Deferred.succeed(promptStarted, undefined).pipe( - Effect.andThen(Deferred.await(promptGate)), - ), }), }), }, + selfInvocation, continuationRequests: { offer: (request) => - Effect.sync(() => continuationRequests.push(request)).pipe( - Effect.andThen(Deferred.succeed(continuationOffered, undefined)), - Effect.asVoid, - ), + Effect.sync(() => { + continuationRequests.push(request); + }), }, }); - const threadId = ThreadId.make("thread-acp-early-command"); + const threadId = ThreadId.make("thread-acp-carryover-root-session-continuation"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -12413,10 +4825,18 @@ describe("AcpAdapterV2", () => { const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-early-command"), + providerSessionId: ProviderSessionId.make( + "provider-session-acp-carryover-root-session-continuation", + ), modelSelection, runtimePolicy, }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; const events = yield* Queue.unbounded(); yield* runtime.events.pipe( Stream.runForEach((event) => Queue.offer(events, event)), @@ -12428,532 +4848,344 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(promptStarted); - assert.isDefined(sessionUpdateHandler); - assert.isDefined(taskCompleted); - const update = (update: EffectAcpSchema.SessionUpdate) => - sessionUpdateHandler!({ sessionId: "mock-session-1", update }); - const complete = (taskId: string, snapshot?: { output?: string; exit_code?: number }) => - taskCompleted!({ - sessionId: "mock-session-1", - update: { sessionUpdate: "task_completed", task_id: taskId, task_snapshot: snapshot }, - }); - for (const taskId of ["early", "blocker"]) { - yield* update({ - sessionUpdate: "tool_call", - toolCallId: taskId, - title: `Run ${taskId}`, - kind: "execute", - status: "completed", - rawInput: { command: `echo ${taskId}` }, - rawOutput: { type: "BackgroundTaskStarted", task_id: taskId }, - }); - } - // Progress removes the ACK carrying the task identity before a reportless completion. - yield* update({ - sessionUpdate: "tool_call_update", - toolCallId: "early", - status: "in_progress", - rawOutput: { type: "Text", text: "Command progress" }, + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Deferred.await(promptSettled); + + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - yield* complete("early", { exit_code: exitCode, output: "EARLY_BG_DONE\n" }); - // The real decoder treats this exit-code-free duplicate as completed, even after failure. - yield* complete("early"); - assert.lengthOf(continuationRequests, 0); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); - let backgroundItem: ProviderAdapter.ProviderAdapterV2Event | undefined; - let checkpoint = 0; - const assertCommand = Effect.fnUntraced(function* (expectedStatus: string, output: string) { - const title = `Checkpoint ${++checkpoint}`; - // A later tool event fences the emitted updates without waiting for a terminal that may never come. - yield* update({ sessionUpdate: "tool_call_update", toolCallId: "blocker", title }); - while (true) { - const event = yield* Queue.take(events); - if (event.type !== "turn_item.updated") continue; - if (event.turnItem.nativeItemRef?.nativeId === "early") backgroundItem = event; - if ( - event.turnItem.nativeItemRef?.nativeId === "blocker" && - event.turnItem.title === title - ) - break; - } - assert.equal(backgroundItem?.type, "turn_item.updated"); - if (backgroundItem?.type !== "turn_item.updated") return; - assert.equal(backgroundItem.turnItem.status, expectedStatus); - assert.equal(backgroundItem.turnItem.type, "command_execution"); - if (backgroundItem.turnItem.type === "command_execution") { - assert.equal(backgroundItem.turnItem.output ?? "", output); + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; } - }); - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - yield* Deferred.await(promptHeld); - yield* assertCommand(status, "EARLY_BG_DONE\n"); - assert.lengthOf(continuationRequests, 0, "the other command still holds the root open"); + } + assert.equal(firstTerminalStatus, "interrupted"); + assert.isTrue( + yield* hasPendingBackgroundWork, + "carryover live subagent must pin hasPendingBackgroundWork after root settle", + ); + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - const replayAck = () => - update({ - sessionUpdate: "tool_call_update", - toolCallId: "early", - status: "completed", - rawOutput: { type: "BackgroundTaskStarted", task_id: "early" }, - }); - yield* replayAck(); - yield* assertCommand(status, "EARLY_BG_DONE\n"); - // Sparse ACP updates must retain the command result after projection replaces the ACK. - for (const sparseStatus of ["completed", "in_progress"] as const) { - yield* update({ + subagentPhase = "complete"; + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { sessionUpdate: "tool_call_update", - toolCallId: "early", - status: sparseStatus, - }); - yield* assertCommand(status, "EARLY_BG_DONE\n"); - } - if (status === "completed") { - for (const output of ["", "UPDATED_OUTPUT\n"]) { - yield* complete("early", { output }); - yield* replayAck(); - yield* assertCommand(status, output); + toolCallId: "tool-call-generic-1", + title: "background subagent", + kind: "other", + status: "completed", + rawOutput: { content: "SUB_DONE" }, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: bufferedAssistantText }, + }, + }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + let completedBeforeDrain = 0; + let polled = yield* Queue.poll(events); + while (Option.isSome(polled)) { + const event = polled.value; + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status === "completed" + ) { + completedBeforeDrain += 1; } + polled = yield* Queue.poll(events); } - yield* complete("blocker", { exit_code: 0, output: "BLOCKER_DONE\n" }); - yield* TestClock.adjust(yield* Deferred.await(finalizeScheduled)); - while (true) { - const event = yield* Queue.take(events); - if (event.type !== "turn.terminal") continue; - assert.equal(event.status, "completed"); - break; - } - yield* Deferred.await(continuationOffered); - yield* complete("early"); - yield* complete("blocker"); + assert.equal( + completedBeforeDrain, + 0, + "interrupted root-session terminal must wait for an observable attach", + ); assert.lengthOf( continuationRequests, 1, - "duplicate completions must not offer another continuation", + "root-session post-settle subagent terminal must offer a continuation", + ); + assert.isTrue( + yield* hasPendingBackgroundWork, + "interrupted unprojected terminal must pin hasPendingBackgroundWork until attach", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.effect("empty-drain continuation turn waits the quiet window so late frames can attach", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined, - isPersistentBackgroundTool: () => true, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), + const userTurnNow = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: userTurnNow, + ordinal: 2, + messageText: "What finished while the prior turn was settling?", }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - }); - const threadId = ThreadId.make("thread-acp-empty-drain-continuation-quiet"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-empty-drain-continuation-quiet", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-late", - title: "Monitor: late complete", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-late", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "completed", - }); - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - const rootProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let rootTerminal: string | null = null; - while (rootTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === rootProviderTurnId) { - rootTerminal = event.status; - } - } - assert.equal(rootTerminal, "completed"); - assert.lengthOf(continuationRequests, 1, "midTurn-only evidence must offer one continuation"); - - const rootInput = makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - }); - yield* runtime - .startTurn({ - ...rootInput, - message: { - ...rootInput.message, - createdBy: "agent", - creationSource: "provider", - text: "Background task completed.", - }, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let polled = yield* Queue.poll(events); - while (Option.isSome(polled)) { - assert.notEqual( - polled.value.type === "turn.terminal" && - polled.value.providerTurnId === continuationProviderTurnId - ? "turn.terminal" - : "", - "turn.terminal", - "empty-drain continuation must not finalize immediately", ); - polled = yield* Queue.poll(events); - } - - assert.isDefined(sessionUpdateHandler); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "LATE_WAKE_FRAME_TOKEN" }, - }, - }); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* TestClock.adjust("3 seconds"); + yield* Effect.yieldNow; + yield* Effect.yieldNow; - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - polled = yield* Queue.poll(events); - let lateFrameSeen = false; - while (Option.isSome(polled)) { - if ( - polled.value.type === "turn_item.updated" && - polled.value.turnItem.type === "assistant_message" && - polled.value.turnItem.text.includes("LATE_WAKE_FRAME_TOKEN") - ) { - lateFrameSeen = true; - } - if ( - polled.value.type === "turn.terminal" && - polled.value.providerTurnId === continuationProviderTurnId - ) { - assert.fail("continuation must not finalize before the quiet window elapses"); + const userProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + }); + let userTurnTerminal: string | null = null; + let completedSubagentTurnItems = 0; + let bufferedTextSeenInUserTurn = false; + while (userTurnTerminal === null) { + const event = yield* Queue.take(events); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status === "completed" + ) { + completedSubagentTurnItems += 1; + } + if ( + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text.includes(bufferedAssistantText) + ) { + bufferedTextSeenInUserTurn = true; + } + if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) { + userTurnTerminal = event.status; + } } - polled = yield* Queue.poll(events); - } - assert.isTrue(lateFrameSeen, "late wake frame must attach to the continuation run"); + assert.equal(userTurnTerminal, "completed"); + assert.equal( + completedSubagentTurnItems, + 1, + "user attach must project the deferred terminal subagent turn item once", + ); + assert.isFalse(bufferedTextSeenInUserTurn, "user attach must not drain wake traffic"); + assert.isTrue( + yield* hasPendingBackgroundWork, + "wake traffic must remain pinned for the already-dispatched continuation", + ); - yield* TestClock.adjust("3 seconds"); - let continuationTerminal: string | null = null; - while (continuationTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) { - continuationTerminal = event.status; + const continuationNow = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: continuationNow, + ordinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", + }), + ); + yield* TestClock.adjust("3 seconds"); + const continuationProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"), + }); + let continuationTerminal: string | null = null; + let bufferedTextSeenInContinuation = false; + while (continuationTerminal === null) { + const event = yield* Queue.take(events); + if ( + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text.includes(bufferedAssistantText) + ) { + bufferedTextSeenInContinuation = true; + } + if ( + event.type === "turn.terminal" && + event.providerTurnId === continuationProviderTurnId + ) { + continuationTerminal = event.status; + } } - } - assert.equal(continuationTerminal, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + assert.equal(continuationTerminal, "completed"); + assert.isTrue( + bufferedTextSeenInContinuation, + "queued continuation must drain the wake content after the user run", + ); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("empty-drain continuation turn finalizes after the quiet window with no frames", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - let sessionUpdateHandler: Parameters[0] | undefined; - const promptGate = yield* Deferred.make(); - const capturedMutation: { - current: - | ((mutation: { - readonly sessionId: string; - readonly taskId: string; - readonly status: "running" | "completed" | "failed"; - }) => Effect.Effect) - | null; - } = { current: null }; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-monitor-late" ? "task-monitor-late" : undefined, - isPersistentBackgroundTool: () => true, - registerExtensions: (context) => - Effect.sync(() => { - capturedMutation.current = context.applyBackgroundTaskMutation; - }), - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, - wrapRuntime: (runtime) => ({ - ...runtime, - handleSessionUpdate: (handler) => + it.effect( + "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("acp-test"); + let subagentPhase: "spawn" | "complete" = "spawn"; + let cancelCalled = false; + let runtimeOrdinalSeen = 0; + const promptSettled = yield* Deferred.make(); + const adapter = yield* makeAcpAdapterV2({ + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), + }, + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + deferFinalizeForBackgroundWork: true, + restartRuntimeAfterInterrupt: true, + restartRuntimeOnEveryInterrupt: true, + terminateRuntimeProcessGroupOnInterrupt: true, + preserveRuntimeOnSettledInterrupt: true, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId !== "tool-call-generic-1" + ? undefined + : subagentPhase === "spawn" + ? { + nativeTaskId: "task-generic-1", + prompt: "background subagent", + title: "background subagent", + model: null, + status: "running", + childSessionId: null, + result: null, + } + : { + nativeTaskId: "task-generic-1", + prompt: "", + title: null, + model: null, + status: "completed", + childSessionId: null, + result: "SUB_DONE", + }, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: (runtimeOrdinal) => { + runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); + return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; + }, + wrapCancel: (cancel) => Effect.sync(() => { - sessionUpdateHandler = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - prompt: () => Deferred.await(promptGate), - }), - }), - }, - selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); + cancelCalled = true; + }).pipe(Effect.andThen(cancel)), }), - }, - }); - const threadId = ThreadId.make("thread-acp-empty-drain-continuation-no-wedge"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-empty-drain-continuation-no-wedge", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isDefined(sessionUpdateHandler); - const applyMutation = capturedMutation.current; - if (applyMutation === null) { - return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); - } - - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call", - toolCallId: "tool-call-monitor-late", - title: "Monitor: late complete", - kind: "execute", - status: "pending", - rawInput: {}, - }, - }); - yield* sessionUpdateHandler!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "tool-call-monitor-late", - status: "in_progress", - }, - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "running", - }); - yield* applyMutation({ - sessionId: "mock-session-1", - taskId: "task-monitor-late", - status: "completed", - }); - yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); - const rootProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - let rootTerminal: string | null = null; - while (rootTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === rootProviderTurnId) { - rootTerminal = event.status; - } - } - assert.equal(rootTerminal, "completed"); - assert.lengthOf(continuationRequests, 1); + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-settled-soft-steer"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Deferred.await(promptSettled); - const rootInput = makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - ordinal: 2, - }); - yield* runtime - .startTurn({ - ...rootInput, - message: { - ...rootInput.message, - createdBy: "agent", - creationSource: "provider", - text: "Background task completed.", - }, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); + assert.isFalse( + cancelCalled, + "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", + ); - const continuationProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - yield* TestClock.adjust("3 seconds"); - let continuationTerminal: string | null = null; - while (continuationTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) { - continuationTerminal = event.status; + let subagentTurnItemId: string | null = null; + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { + subagentTurnItemId = event.turnItem.id; + } + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; + } } - } - assert.equal(continuationTerminal, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + assert.equal(firstTerminalStatus, "interrupted"); + assert.notEqual(subagentTurnItemId, null); + + subagentPhase = "complete"; + const secondNow = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: secondNow, + ordinal: 2, + }), + ); + const secondProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + }); + let carriedItemStatus: string | null = null; + let secondTerminalStatus: string | null = null; + while (secondTerminalStatus === null) { + const event = yield* Queue.take(events); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.id === subagentTurnItemId + ) { + carriedItemStatus = event.turnItem.status; + } + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { + secondTerminalStatus = event.status; + } + } + assert.equal(carriedItemStatus, "completed"); + assert.equal(secondTerminalStatus, "completed"); + assert.equal( + runtimeOrdinalSeen, + 1, + "settled soft steer must not respawn the ACP runtime process", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("restarts the ACP child process before the next prompt after interrupt", () => + it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -12970,15 +5202,19 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, restartRuntimeAfterInterrupt: true, + restartRuntimeOnEveryInterrupt: true, + terminateRuntimeProcessGroupOnInterrupt: true, + preserveRuntimeOnSettledInterrupt: true, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, protocolEvents, }), }, selfInvocation, }); - const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); + const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -12987,7 +5223,9 @@ describe("AcpAdapterV2", () => { const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-restart-after-interrupt"), + providerSessionId: ProviderSessionId.make( + "provider-session-acp-unsettled-steer-stays-hard", + ), modelSelection, runtimePolicy, }); @@ -12997,9 +5235,11 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ); + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( (event) => @@ -13009,226 +5249,38 @@ describe("AcpAdapterV2", () => { ); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId( - instanceId, - `${providerThread.nativeThreadRef?.nativeId}:turn:1`, - ), - }); - yield* runtime.interruptTurn({ - providerThread, - providerTurnId, - requestRuntimeRestart: true, - }); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", - ), - Stream.runHead, - ); - - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ); - const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume", - ), - Stream.runHead, - ); - assert.isTrue( - Option.isSome(loadAfterRestart), - "post-interrupt startTurn should respawn the runtime and replay session/resume", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.effect("Windows teardown is one-shot explicitly with independent finalizer cleanup", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const runtimeScope = yield* Scope.make(); - const taskkillCommands: Array<{ - readonly command: string; - readonly args: ReadonlyArray; - }> = []; - const taskkillSpawner = makeTaskkillSpawner({ exitCode: 0, commands: taskkillCommands }); - const spawner = ChildProcessSpawner.make((command) => { - const value = command as unknown as { readonly command: string }; - return value.command === "taskkill" - ? taskkillSpawner.spawn(command) - : childProcessSpawner.spawn(command); + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const context = yield* Layer.build( - AcpSessionRuntime.layer({ - spawn: { - command: process.execPath, - args: [mockAgentPath], - cwd: process.cwd(), - env: { T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-acp-test", version: "0.0.0" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), - ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); - const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( - Effect.provide(context), - ); - assert.isDefined(runtime.terminateProcessGroup); - yield* Effect.all([runtime.terminateProcessGroup!, runtime.terminateProcessGroup!], { - concurrency: "unbounded", - }); - assert.equal(taskkillCommands.length, 1); - yield* Scope.close(runtimeScope, Exit.void); - // The scope finalizer picks its kill path from the host, not processGroupPlatform, - // so only a Windows host sends it through taskkill a second time. - const finalizerTaskkills = (yield* HostProcess.Platform) === "win32" ? 1 : 0; - assert.equal(taskkillCommands.length, 1 + finalizerTaskkills); - assert.deepEqual(taskkillCommands.at(-1), taskkillCommands[0]); - }).pipe(Effect.provide(layerTest)), - ); - - it("accepts only taskkill exit code zero as successful tree termination", () => { - assert.isTrue(AcpSessionRuntime.windowsTaskkillResultIsSuccess(0, "")); - assert.isFalse( - AcpSessionRuntime.windowsTaskkillResultIsSuccess( - 128, - "FEHLER: Der Prozess wurde nicht gefunden.", - ), - ); - assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(128, "")); - assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(1, "localized failure")); - assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(255, "")); - }); - - it.effect("runs the default taskkill path and preserves every failure mode", () => - Effect.gen(function* () { - const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = - []; - yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeTaskkillSpawner({ exitCode: 0, commands }), - ), - ); - assert.deepEqual(commands, [{ command: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]); - - for (const fixture of [ - { exitCode: 128, output: 'ERROR: The process "4321" not found.' }, - { exitCode: 128, output: "FEHLER: Prozess nicht gefunden." }, - { exitCode: 128, output: "" }, - { exitCode: 128, output: "ERROR: Access is denied." }, - { exitCode: 1, output: "generic failure" }, - ]) { - const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeTaskkillSpawner(fixture), - ), - Effect.exit, - ); - if (Exit.isSuccess(failed)) assert.fail(`taskkill ${fixture.exitCode} must fail`); - const error = Cause.squash(failed.cause); - assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); - const termination = error as AcpSessionRuntime.AcpProcessGroupTerminationError; - assert.equal( - termination.detail, - `taskkill exited ${fixture.exitCode} for ACP process tree 4321`, - ); - assert.equal(termination.pid, 4321); - assert.equal(termination.exitCode, fixture.exitCode); - if (fixture.output.length > 0) { - assert.equal(termination.cause, fixture.output); - } - } - - for (const fixture of [ - { spawnFailure: true }, - { outputFailure: true }, - { exitFailure: true }, - ]) { - const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(4321).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - makeTaskkillSpawner(fixture), - ), - Effect.exit, - ); - if (Exit.isSuccess(failed)) assert.fail("taskkill infrastructure failure must fail"); - const error = Cause.squash(failed.cause); - assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); - assert.equal( - (error as AcpSessionRuntime.AcpProcessGroupTerminationError).detail, - "Failed to run taskkill for ACP process tree 4321", - ); + const interruptExit = yield* runtime + .interruptTurn({ providerThread, providerTurnId }) + .pipe(Effect.exit); + if (Exit.isSuccess(interruptExit)) { + assert.fail("mid-prompt steering interrupt must still take the hard teardown path"); } - }), - ); - - it.effect("Windows process-tree teardown surfaces taskkill failure", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const context = yield* Layer.build( - AcpSessionRuntime.layer({ - spawn: { command: process.execPath, args: [mockAgentPath], cwd: process.cwd() }, - cwd: process.cwd(), - clientInfo: { name: "t3-acp-test", version: "0.0.0" }, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => - Effect.fail( - new AcpSessionRuntime.AcpProcessGroupTerminationError({ - detail: "mock taskkill failure", - }), - ), - }).pipe( - Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - ), - ); - const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( - Effect.provide(context), - ); - const error = yield* Effect.flip(runtime.terminateProcessGroup!); - assert.equal(error.detail, "mock taskkill failure"); + assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("poisons the session when hard teardown defects and blocks replacement work", () => + it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const commandPidPath = yield* fileSystem.makeTempFileScoped({ - prefix: "t3-acp-failed-teardown-command-", - }); - const residualCallbackDir = yield* fileSystem.makeTempDirectoryScoped(); - const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log"); - const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger"); const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const teardownStarted = yield* Deferred.make(); - const releaseTeardown = yield* Deferred.make(); - let terminatorCallCount = 0; const instanceId = ProviderInstanceId.make("acp-test"); + const capturedMutation: { + current: + | ((mutation: { + readonly sessionId: string; + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + }) => Effect.Effect) + | null; + } = { current: null }; const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { @@ -13237,55 +5289,45 @@ describe("AcpAdapterV2", () => { enablePostSettleContinuation: true, restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, + preserveRuntimeOnSettledInterrupt: true, + registerExtensions: (context) => + Effect.sync(() => { + capturedMutation.current = context.applyBackgroundTaskMutation; + }), makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => - Effect.gen(function* () { - terminatorCallCount += 1; - yield* Deferred.succeed(teardownStarted, undefined); - yield* Deferred.await(releaseTeardown); - return yield* Effect.die("mock taskkill defect"); - }), - environment: { - T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", - T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH: residualCallbackResponseLogPath, - T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH: residualCallbackTriggerPath, - T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, - }, protocolEvents, }), }, selfInvocation, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, + continuationRequests: { offer: () => Effect.void }, }); - const threadId = ThreadId.make("thread-acp-failed-hard-teardown"); + const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const sessionScope = yield* Scope.make(); - const runtime = yield* adapter - .openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-failed-hard-teardown"), - modelSelection, - runtimePolicy, - }) - .pipe(Effect.provideService(Scope.Scope, sessionScope)); - const adapterEvents = yield* Queue.unbounded(); + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"), + modelSelection, + runtimePolicy, + }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(adapterEvents, event)), - Effect.forkIn(sessionScope), + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, ); const providerThread = yield* runtime.ensureThread({ threadId, @@ -13298,127 +5340,188 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), ) .pipe(Effect.forkScoped); - while ((yield* fileSystem.readFileString(commandPidPath)).trim().length === 0) { - yield* Effect.yieldNow; - } - const [commandRootPid, commandSleepPid] = (yield* fileSystem.readFileString(commandPidPath)) - .trim() - .split(/\s+/) - .map(Number); - assert.isTrue( - Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), - "declared failed-teardown Bash and sleep PIDs must both become live", - ); - yield* pollProtocolMethods(protocolEvents); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) - .pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.await(teardownStarted); - const startFiber = yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ) - .pipe(Effect.exit, Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isUndefined(startFiber.pollUnsafe()); - const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); - yield* Deferred.succeed(releaseTeardown, undefined); - assert.notInclude(methodsDuringTeardown, "session/resume"); - assert.notInclude(methodsDuringTeardown, "session/prompt"); - - const interruptExit = yield* Fiber.join(interruptFiber); - if (Exit.isSuccess(interruptExit)) assert.fail("hard teardown failure must fail interrupt"); - assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); - assert.isTrue( - Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), - "failed teardown must leave both declared Bash and sleep PIDs live", - ); - - while (Option.isSome(yield* Queue.poll(adapterEvents))) {} - yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go"); yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( (event) => - event.direction === "incoming" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"method":"session/request_permission"'), + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", ), Stream.runHead, ); + const applyMutation = capturedMutation.current; + if (applyMutation === null) { + return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); + } + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-pre-stop", + status: "running", + }); assert.isTrue( - Option.isNone(yield* Queue.poll(adapterEvents)), - "residual callbacks must not mutate or emit adapter projection after poison", + yield* hasPendingBackgroundWork, + "a running background task mutation on the root session must track before Stop", ); + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-pre-stop", + status: "completed", + }); assert.isFalse( - yield* fileSystem.exists(residualCallbackResponseLogPath), - "permission and elicitation callbacks must remain unresolved after poison", - ); - assert.lengthOf( - continuationRequests, - 0, - "residual callbacks must not offer a continuation after poison", + yield* hasPendingBackgroundWork, + "a completed background task mutation must clear pending background work", ); - const startExit = yield* Fiber.join(startFiber); - if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); - assert.include(Cause.pretty(startExit.cause), "session is poisoned"); - const resumeExit = yield* runtime - .resumeThread({ providerThread, modelSelection, runtimePolicy }) - .pipe(Effect.exit); - if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); - assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); - const snapshotExit = yield* runtime.readThreadSnapshot({ providerThread }).pipe(Effect.exit); - if (Exit.isSuccess(snapshotExit)) { - assert.fail("poisoned session must reject readThreadSnapshot"); - } - assert.include(Cause.pretty(snapshotExit.cause), "session is poisoned"); - const forkExit = yield* runtime - .forkThread({ - sourceProviderThread: providerThread, - targetThreadId: ThreadId.make("thread-acp-poisoned-fork"), - }) - .pipe(Effect.exit); - if (Exit.isSuccess(forkExit)) assert.fail("poisoned session must reject forkThread"); - assert.include(Cause.pretty(forkExit.cause), "session is poisoned"); - yield* pollProtocolMethods(protocolEvents); - const retryInterruptExit = yield* runtime - .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) - .pipe(Effect.exit); - if (Exit.isSuccess(retryInterruptExit)) { - assert.fail("poisoned session must reject a repeated hard interrupt"); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); + let terminalStatus: string | null = null; + while (terminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + terminalStatus = event.status; + } } - const firstInterruptError = Cause.squash(interruptExit.cause) as Error; - const retryInterruptError = Cause.squash(retryInterruptExit.cause) as Error; - assert.strictEqual(retryInterruptError.cause, firstInterruptError.cause); - assert.equal(terminatorCallCount, 1); - const methodsAfterPoison = yield* pollProtocolMethods(protocolEvents); - assert.notInclude(methodsAfterPoison, "initialize"); - assert.notInclude(methodsAfterPoison, "session/cancel"); - assert.notInclude(methodsAfterPoison, "session/fork"); - assert.notInclude(methodsAfterPoison, "session/resume"); - assert.notInclude(methodsAfterPoison, "session/prompt"); - assert.isTrue(processExists(commandRootPid!)); - assert.isTrue(processExists(commandSleepPid!)); - yield* Scope.close(sessionScope, Exit.void); - assert.isTrue( - Option.isSome(yield* waitForProcessesToExit([commandRootPid!, commandSleepPid!])), - "finalizer cleanup must reap both declared Bash and sleep PIDs", + assert.equal(terminalStatus, "interrupted"); + + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-late-after-stop", + status: "running", + }); + assert.isFalse( + yield* hasPendingBackgroundWork, + "direct Stop quarantine must drop residual background task mutations from the stopped run", ); - assert.isFalse(processExists(commandRootPid!)); - assert.isFalse(processExists(commandSleepPid!)); - const finalizerMethods = yield* pollProtocolMethods(protocolEvents); - assert.notInclude(finalizerMethods, "session/close"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("durably poisons start and resume when required hard teardown is unavailable", () => + it.effect( + "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const continuationRequests: Array = + []; + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + enablePostSettleContinuation: true, + extractBackgroundTaskId: (toolCall) => + toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, + extractBackgroundTaskCompletion: (toolCall) => + toolCall.toolCallId === "tool-call-fetch-1" + ? [ + { + taskId: "task-monitor-1", + status: toolCall.status === "completed" ? "completed" : "running", + appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", + }, + ] + : [], + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { + T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1", + }, + protocolEvents, + }), + }, + selfInvocation, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + }); + const threadId = ThreadId.make("thread-acp-already-handled-wake-pin"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-already-handled-wake-pin", + ), + modelSelection, + runtimePolicy, + }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + + let terminalStatus: string | null = null; + while (terminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + terminalStatus = event.status; + } + } + assert.equal(terminalStatus, "completed"); + + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "incoming" && + event.stage === "raw" && + typeof event.payload === "string" && + event.payload.includes("MONITOR_LISTING_TOKEN_LATE"), + ), + Stream.runHead, + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + assert.lengthOf( + continuationRequests, + 0, + "already-handled late TaskOutput must not open a continuation run", + ); + assert.isFalse( + yield* hasPendingBackgroundWork, + "wake buffer must not stay non-empty and pin idle release after an already-handled re-report", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect("keeps a buffered continuation current when a user turn starts before dispatch", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -13427,25 +5530,54 @@ describe("AcpAdapterV2", () => { const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); + const continuationRequests: Array = + []; + const userPromptStarted = yield* Deferred.make(); + const releaseUserPrompt = yield* Deferred.make(); + const bufferedAssistantText = "BUFFERED_WAKE_QUEUED_AFTER_USER"; + let promptOrdinal = 0; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, - protocolEvents, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: (payload) => + Effect.gen(function* () { + promptOrdinal += 1; + const currentPromptOrdinal = promptOrdinal; + const result = yield* runtime.prompt(payload); + if (currentPromptOrdinal === 2) { + yield* Deferred.succeed(userPromptStarted, undefined); + yield* Deferred.await(releaseUserPrompt); + } + return result; + }), + }), }), }, selfInvocation, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, }); - const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); + const threadId = ThreadId.make("thread-acp-buffered-continuation-user-race"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -13454,554 +5586,775 @@ describe("AcpAdapterV2", () => { const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"), + providerSessionId: ProviderSessionId.make( + "provider-session-acp-buffered-continuation-user-race", + ), modelSelection, runtimePolicy, }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy, }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), ); - const providerTurnId = idAllocator.derive.providerTurn({ + const firstProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptExit = yield* runtime - .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) - .pipe(Effect.exit); - if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt"); - assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; + } + } + assert.equal(firstTerminalStatus, "completed"); + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); + + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: bufferedAssistantText }, + }, + }); + assert.lengthOf(continuationRequests, 1); + const continuationRequest = continuationRequests[0]!; + assert.isDefined(continuationRequest.dispatchIfCurrent); + + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 2, + messageText: "User turn won the continuation dispatch race.", + }), + ); + yield* Deferred.await(userPromptStarted); + let continuationDispatched = false; + const dispatchOutcome = yield* continuationRequest.dispatchIfCurrent!( + Effect.sync(() => { + continuationDispatched = true; + }), + ); + assert.isTrue( + Option.isSome(dispatchOutcome), + "queue_after_active dispatch must remain current while the user run is active", + ); + assert.isTrue(continuationDispatched); + yield* Deferred.succeed(releaseUserPrompt, undefined); + + const userProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + }); + let userTerminalStatus: string | null = null; + while (userTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === userProviderTurnId) { + userTerminalStatus = event.status; + } + } + assert.equal(userTerminalStatus, "completed"); + assert.isTrue( + yield* hasPendingBackgroundWork, + "the queued continuation must retain ownership of its buffered wake traffic", + ); + + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", + }), + ); + yield* TestClock.adjust("3 seconds"); + const continuationProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:3"), + }); + let continuationTerminalStatus: string | null = null; + let bufferedTextSeen = false; + while (continuationTerminalStatus === null) { + const event = yield* Queue.take(events); + if ( + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text.includes(bufferedAssistantText) + ) { + bufferedTextSeen = true; + } + if (event.type === "turn.terminal" && event.providerTurnId === continuationProviderTurnId) { + continuationTerminalStatus = event.status; + } + } + assert.equal(continuationTerminalStatus, "completed"); + assert.isTrue(bufferedTextSeen, "continuation must drain the wake buffer after the user run"); + assert.isFalse(yield* hasPendingBackgroundWork); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect( + "staggered pre-settle completion keeps midTurn marks until last background task ends post-finalize", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const continuationRequests: Array = + []; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + const promptGate = yield* Deferred.make(); + const capturedMutation: { + current: + | ((mutation: { + readonly sessionId: string; + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + }) => Effect.Effect) + | null; + } = { current: null }; + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + enablePostSettleContinuation: true, + extractBackgroundTaskId: (toolCall) => { + if (toolCall.toolCallId === "tool-call-monitor-a") return "task-monitor-a"; + if (toolCall.toolCallId === "tool-call-monitor-b") return "task-monitor-b"; + return undefined; + }, + extractBackgroundToolMutation: (text) => { + if (text.includes('Monitor "task-monitor-a" ended')) { + return [{ taskId: "task-monitor-a", status: "completed", appendOutput: "" }]; + } + if (text.includes('Monitor "task-monitor-b" ended')) { + return [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }]; + } + return []; + }, + registerExtensions: (context) => + Effect.sync(() => { + capturedMutation.current = context.applyBackgroundTaskMutation; + }), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + protocolEvents, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: () => Deferred.await(promptGate), + }), + }), + }, + selfInvocation, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + }); + const threadId = ThreadId.make("thread-acp-staggered-midturn-keep-until-last"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-staggered-midturn-keep-until-last", + ), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.isDefined(sessionUpdateHandler); + const applyMutation = capturedMutation.current; + if (applyMutation === null) { + return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); + } - const startExit = yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ) - .pipe(Effect.exit); - if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); - assert.include(Cause.pretty(startExit.cause), "session is poisoned"); - const resumeExit = yield* runtime - .resumeThread({ providerThread, modelSelection, runtimePolicy }) - .pipe(Effect.exit); - if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); - assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); + for (const [toolCallId, title] of [ + ["tool-call-monitor-a", "Monitor: staggered A"], + ["tool-call-monitor-b", "Monitor: staggered B"], + ] as const) { + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId, + title, + kind: "execute", + status: "pending", + rawInput: {}, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + } + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-monitor-a", + status: "running", + }); + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-monitor-b", + status: "running", + }); + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-monitor-a", + status: "completed", + }); + assert.lengthOf( + continuationRequests, + 0, + "pre-settle unhandled completion must not offer while the turn is active", + ); - it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const teardownStarted = yield* Deferred.make(); - const releaseTeardown = yield* Deferred.make(); - let runtimeOrdinalSeen = 0; - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { + yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); + const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - interruptPromptOnCancel: true, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseTeardown)), - ), - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = runtimeOrdinal; - return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + let terminalStatus: string | null = null; + while (terminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + terminalStatus = event.status; + } + } + assert.equal(terminalStatus, "completed"); + assert.lengthOf( + continuationRequests, + 0, + "finalize must not offer while B is still running", + ); + + const bEndNotice = { + sessionId: "mock-session-1", + update: { + sessionUpdate: "user_message_chunk" as const, + content: { + type: "text" as const, + text: 'Monitor "task-monitor-b" ended: [monitor ended: exit 0]', }, - protocolEvents, + }, + }; + assert.isFalse( + acpPostSettleWakeEvidence(bEndNotice, { + extractBackgroundToolMutation: (text) => + text.includes('Monitor "task-monitor-b" ended') + ? [{ taskId: "task-monitor-b", status: "completed", appendOutput: "" }] + : [], }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* pollProtocolMethods(protocolEvents); - const interruptFiber = yield* runtime - .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) - .pipe(Effect.forkScoped); - yield* Deferred.await(teardownStarted); - const startFiber = yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isUndefined(startFiber.pollUnsafe()); - assert.equal(runtimeOrdinalSeen, 1); - const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); - assert.notInclude(methodsDuringTeardown, "session/resume"); - assert.notInclude(methodsDuringTeardown, "session/prompt"); - - yield* Deferred.succeed(releaseTeardown, undefined); - yield* Fiber.join(interruptFiber); - yield* Fiber.join(startFiber); - assert.equal(runtimeOrdinalSeen, 2); - const replacementMethods = yield* pollProtocolMethods(protocolEvents); - assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1); - if (!replacementMethods.includes("session/prompt")) { - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, + "mutation-only end-notice must not count as post-settle wake evidence", ); - } - }).pipe(Effect.provide(layerTest), Effect.scoped), + yield* sessionUpdateHandler!(bEndNotice); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.lengthOf( + continuationRequests, + 1, + "exactly one continuation when the last running task ends post-finalize with kept midTurn marks", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("quarantines old-runtime callbacks after successful hard teardown", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const continuationRequests: Array = - []; - const responseLifecycle: Array = []; - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - type HandlerRecord = { - sessionUpdate?: Parameters[0]; - permission?: Parameters[0]; - elicitation?: Parameters[0]; - requestUserInput?: AcpAdapterV2ExtensionContext["requestUserInput"]; - }; - const handlerRecords: HandlerRecord[] = []; - const runtimeInputs: AcpAdapterV2RuntimeInput[] = []; - const oldPromptCompletion = yield* Deferred.make(); - const transportDrained = yield* Deferred.make(); - const releaseTransportDrain = yield* Deferred.make(); - let extensionOrdinal = 0; - let runtimeOrdinalSeen = 0; - const instanceId = ProviderInstanceId.make("acp-test"); - const makeRuntime = makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => Effect.void, - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = runtimeOrdinal; - return { T3_ACP_HANG_PROMPT_FOREVER: "1" }; - }, - protocolEvents, - wrapRuntime: (runtime, runtimeOrdinal) => { - const record: HandlerRecord = {}; - handlerRecords[runtimeOrdinal - 1] = record; - return { - ...runtime, - handleSessionUpdate: (handler) => - Effect.sync(() => { - record.sessionUpdate = handler; - }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), - handleRequestPermission: (handler) => - Effect.sync(() => { - record.permission = handler; - }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))), - handleElicitation: (handler) => + it.effect( + "two-turn in-turn monitors never open a wake run or retain turn-1 injected-turn ack chatter", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const continuationRequests: Array = + []; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + const promptGates: Array> = []; + const capturedMutation: { + current: + | ((mutation: { + readonly sessionId: string; + readonly taskId: string; + readonly status: "running" | "completed" | "failed"; + }) => Effect.Effect) + | null; + } = { current: null }; + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + enablePostSettleContinuation: true, + extractBackgroundTaskId: (toolCall) => { + if (toolCall.toolCallId === "tool-call-monitor-1") return "task-monitor-1"; + if (toolCall.toolCallId === "tool-call-monitor-2") return "task-monitor-2"; + return undefined; + }, + extractBackgroundToolMutation: (text) => { + if (text.includes('Monitor "task-monitor-1" ended')) { + return [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]; + } + if (text.includes('Monitor "task-monitor-2" ended')) { + return [{ taskId: "task-monitor-2", status: "completed", appendOutput: "" }]; + } + return []; + }, + extractBackgroundTaskCompletion: (toolCall) => { + if (toolCall.toolCallId === "tool-call-fetch-1") { + return [ + { + taskId: "task-monitor-1", + status: toolCall.status === "completed" ? "completed" : "running", + appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", + }, + ]; + } + if (toolCall.toolCallId === "tool-call-fetch-2") { + return [ + { + taskId: "task-monitor-2", + status: toolCall.status === "completed" ? "completed" : "running", + appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN_2" : "", + }, + ]; + } + return []; + }, + registerExtensions: (context) => Effect.sync(() => { - record.elicitation = handler; - }).pipe(Effect.andThen(runtime.handleElicitation(handler))), - ...(runtimeOrdinal === 1 - ? { - prompt: (payload) => - Effect.gen(function* () { - yield* runtime.prompt(payload).pipe(Effect.ignore, Effect.forkDetach); - return yield* Deferred.await(oldPromptCompletion); - }), - } - : {}), - }; - }, - }); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - registerExtensions: ({ requestUserInput }) => - Effect.sync(() => { - handlerRecords[extensionOrdinal++]!.requestUserInput = requestUserInput; - }), - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: (runtimeInput) => - Effect.sync(() => { - runtimeInputs.push(runtimeInput); - }).pipe(Effect.andThen(makeRuntime(runtimeInput))), - }, - selfInvocation, - testHooks: { - afterHardTeardownTransportDrained: () => - Deferred.succeed(transportDrained, undefined).pipe( - Effect.andThen(Deferred.await(releaseTransportDrain)), - ), - onNativeResponseLifecycle: (event) => - Effect.sync(() => { - responseLifecycle.push(event.type); - }), - }, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); + capturedMutation.current = context.applyBackgroundTaskMutation; + }), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + protocolEvents, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + promptGates.push(gate); + return yield* Deferred.await(gate); + }), + }), }), - }, - }); - const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-successful-teardown-callback-quarantine", - ), - modelSelection, - runtimePolicy, - }); - const adapterEvents = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(adapterEvents, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const permissionRequest = { - sessionId: "mock-session-1", - toolCall: { - toolCallId: "stale-generation-1-permission", - title: "Stale generation 1 permission", - }, - options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], - }; - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(transportDrained); - const oldHandlers = handlerRecords[0]!; - assert.isDefined(oldHandlers.permission); - const heldInboundFiber = yield* oldHandlers.permission!(permissionRequest, { - requestId: "post-drain-stale-permission-id", - method: "session/request_permission", - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - assert.isUndefined(heldInboundFiber.pollUnsafe()); - yield* Deferred.succeed(releaseTransportDrain, undefined); - yield* Fiber.join(interruptFiber); - yield* Effect.yieldNow; - assert.isUndefined(heldInboundFiber.pollUnsafe()); - assert.notInclude(responseLifecycle, "registered"); - assert.notInclude(responseLifecycle, "watcher_started"); - assert.isFalse( - (yield* Queue.takeAll(adapterEvents)).some( - (event) => event.type === "runtime_request.updated", - ), - "post-drain inbound callback must not emit a runtime request", - ); - assert.equal(runtimeOrdinalSeen, 1); - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ); - yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", - ), - Stream.runHead, - ); - assert.equal(runtimeOrdinalSeen, 2); - const replacementHandlers = handlerRecords[1]!; - assert.isDefined(oldHandlers.sessionUpdate); - assert.isDefined(oldHandlers.permission); - assert.isDefined(oldHandlers.elicitation); - assert.isDefined(oldHandlers.requestUserInput); - assert.isDefined(replacementHandlers.sessionUpdate); - assert.isDefined(replacementHandlers.permission); - assert.isDefined(replacementHandlers.elicitation); - assert.isDefined(replacementHandlers.requestUserInput); - assert.lengthOf(runtimeInputs, 2); - while (Option.isSome(yield* Queue.poll(adapterEvents))) {} + }, + selfInvocation, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, + }); + const threadId = ThreadId.make("thread-acp-multiturn-in-turn-monitor-no-wake"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-multiturn-in-turn-monitor-no-wake", + ), + modelSelection, + runtimePolicy, + }); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die( + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); + while (promptGates.length < 1) { + yield* Effect.yieldNow; + } + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); + const applyMutation = capturedMutation.current; + if (applyMutation === null) { + return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); + } - yield* oldHandlers.sessionUpdate!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "stale generation 1 assistant" }, - }, - }); - yield* oldHandlers.sessionUpdate!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "tool_call_update", - toolCallId: "stale-generation-1-tool", - title: "Stale generation 1 tool", - kind: "other", + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-call-monitor-1", + title: "Monitor: first turn", + kind: "execute", + status: "pending", + rawInput: {}, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-monitor-1", + status: "in_progress", + }, + }); + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-monitor-1", + status: "running", + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-call-fetch-1", + title: "get_command_or_subagent_output", + kind: "other", + status: "pending", + rawInput: { task_id: "task-monitor-1" }, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-fetch-1", + status: "completed", + rawOutput: { output: "MONITOR_LISTING_TOKEN" }, + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Monitor listing ready in-turn." }, + }, + }); + yield* applyMutation({ + sessionId: "mock-session-1", + taskId: "task-monitor-1", status: "completed", - rawOutput: { output: "stale continuation evidence" }, - }, - }); - const oldPermissionFiber = yield* oldHandlers.permission!(permissionRequest, { - requestId: "stale-generation-1-permission-id", - method: "session/request_permission", - }).pipe(Effect.exit, Effect.forkScoped); - const oldElicitationFiber = yield* oldHandlers.elicitation!( - { + }); + assert.lengthOf(continuationRequests, 0); + + yield* Deferred.succeed(promptGates[0]!, { stopReason: "end_turn" }); + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + let firstTerminal: string | null = null; + while (firstTerminal === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminal = event.status; + } + } + assert.equal(firstTerminal, "completed"); + + yield* sessionUpdateHandler!({ sessionId: "mock-session-1", - message: "Stale generation 1 elicitation", - mode: "form", - requestedSchema: { - type: "object", - properties: { approved: { type: "boolean", title: "Approved" } }, + update: { + sessionUpdate: "user_message_chunk", + content: { + type: "text", + text: 'Monitor "task-monitor-1" ended', + }, }, - }, - { - requestId: "stale-generation-1-elicitation-id", - method: "session/elicitation", - }, - ).pipe(Effect.exit, Effect.forkScoped); - const oldXAiUserInputFiber = yield* oldHandlers.requestUserInput!( - { - nativeItemId: "stale-generation-1-xai-item", - nativeRequestId: "stale-generation-1-xai-request", - questions: [ - { - id: "approved", - header: "Approve", - question: "Approve stale generation 1?", - options: [{ label: "yes", description: "Approve" }], + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "That monitor event is the same run I already summarized.", }, - ], - }, - { - requestId: "stale-generation-1-xai-transport-id", - method: "_x.ai/ask_user_question", - }, - ).pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.succeed(oldPromptCompletion, { stopReason: "end_turn" }); - yield* Effect.sleep("100 millis"); - assert.isTrue(Option.isNone(yield* Queue.poll(adapterEvents))); - assert.isUndefined(oldPermissionFiber.pollUnsafe()); - assert.isUndefined(oldElicitationFiber.pollUnsafe()); - assert.isUndefined(oldXAiUserInputFiber.pollUnsafe()); - assert.lengthOf(continuationRequests, 0); - - yield* replacementHandlers.sessionUpdate!({ - sessionId: "mock-session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "live generation 2 assistant" }, - }, - }); - let replacementMessageSeen = false; - while (!replacementMessageSeen) { - const event = yield* Queue.take(adapterEvents); - replacementMessageSeen = - event.type === "message.updated" && event.message.text.includes("live generation 2"); - } - const replacementPermission = yield* replacementHandlers.permission!(permissionRequest, { - requestId: "live-generation-2-permission-id", - method: "session/request_permission", - }); - assert.equal(replacementPermission.outcome.outcome, "selected"); - yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-permission-id"); - const urlElicitation = { - elicitationId: "replacement-url-id", - message: "Open replacement URL", - mode: "url" as const, - sessionId: "mock-session-1", - url: "https://example.com/replacement", - }; - yield* replacementHandlers.elicitation!(urlElicitation, { - requestId: "live-generation-2-url-id", - method: "session/elicitation", - }); - yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-url-id"); - - const replacementUserInputFiber = yield* replacementHandlers.requestUserInput!( - { - nativeItemId: "live-generation-2-xai-item", - nativeRequestId: "shared-request-id", - questions: [ - { - id: "approved", - header: "Approve", - question: "Approve live generation 2?", - options: [{ label: "yes", description: "Approve" }], + }, + }); + yield* sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "That monitor event is the same run I already summarized above.", }, - ], - }, - { - requestId: "live-generation-2-transport-id", - method: "_x.ai/ask_user_question", - }, - ).pipe(Effect.forkScoped); - let replacementRequest: ProviderAdapter.ProviderAdapterV2Event | undefined; - while (replacementRequest === undefined) { - const event = yield* Queue.take(adapterEvents); - if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") { - replacementRequest = event; + }, + }); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.lengthOf( + continuationRequests, + 0, + "turn-1 injected-turn acks must not open a continuation", + ); + assert.isFalse( + yield* hasPendingBackgroundWork, + "turn-1 injected-turn ack chatter must not remain as wake buffer evidence", + ); + + yield* runtime + .startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 2, + }), + ) + .pipe(Effect.forkScoped); + while (promptGates.length < 2) { + yield* Effect.yieldNow; + } + if (sessionUpdateHandler === undefined || capturedMutation.current === null) { + return yield* Effect.die("extensions and session handler must be wired for turn 2"); } - } - if (replacementRequest.type !== "runtime_request.updated") { - return yield* Effect.die("Expected generation 2 xAI user input request"); - } - const responseFiber = yield* runtime - .respondToRuntimeRequest({ - requestId: replacementRequest.runtimeRequest.id, - answers: { approved: ["yes"] }, - }) - .pipe(Effect.forkScoped); - const replacementUserInput = yield* Fiber.join(replacementUserInputFiber); - assert.deepEqual(replacementUserInput.answers, { approved: ["yes"] }); - yield* replacementUserInput.acknowledgeNativeResponse; - yield* runtimeInputs[0]!.onOutgoingResponse!("stale-generation-1-transport-id"); - yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-wrong-method-id"); - yield* Effect.yieldNow; - assert.isUndefined(responseFiber.pollUnsafe()); - yield* runtimeInputs[1]!.onOutgoingResponse!("live-generation-2-transport-id"); - yield* Fiber.join(responseFiber); - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ + yield* sessionUpdateHandler({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-call-monitor-2", + title: "Monitor: second turn", + kind: "execute", + status: "pending", + rawInput: {}, + }, + }); + yield* sessionUpdateHandler({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-monitor-2", + status: "in_progress", + }, + }); + yield* capturedMutation.current({ + sessionId: "mock-session-1", + taskId: "task-monitor-2", + status: "running", + }); + yield* sessionUpdateHandler({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-call-fetch-2", + title: "get_command_or_subagent_output", + kind: "other", + status: "pending", + rawInput: { task_id: "task-monitor-2" }, + }, + }); + yield* sessionUpdateHandler({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-call-fetch-2", + status: "completed", + rawOutput: { output: "MONITOR_LISTING_TOKEN_2" }, + }, + }); + yield* sessionUpdateHandler({ + sessionId: "mock-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Second monitor listing ready in-turn." }, + }, + }); + yield* capturedMutation.current({ + sessionId: "mock-session-1", + taskId: "task-monitor-2", + status: "completed", + }); + assert.lengthOf( + continuationRequests, + 0, + "mid-turn task_completed for an in-turn-handled monitor must not open a wake run", + ); + + yield* Deferred.succeed(promptGates[1]!, { stopReason: "end_turn" }); + const secondProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }), - requestRuntimeRestart: true, - }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }); + let secondTerminal: string | null = null; + while (secondTerminal === null) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { + secondTerminal = event.status; + } + } + assert.equal(secondTerminal, "completed"); + assert.lengthOf( + continuationRequests, + 0, + "no continuation must be offered across the multiturn in-turn monitor sequence", + ); + assert.isFalse( + yield* hasPendingBackgroundWork, + "wake buffer must not retain turn-1 or turn-2 in-turn-handled ack residue", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("keeps stale deferred cleanup inert while replacement requests remain live", () => + it.effect("a wake names work that ended while the previous wake was queued", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); - const protocolEvents = yield* Queue.bounded(256); + const continuationRequests: Array = + []; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + let applyMutation: AcpAdapterV2ExtensionContext["applyBackgroundTaskMutation"] | undefined; + const promptGate = yield* Deferred.make(); const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - restartRuntimeAfterInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, + enablePostSettleContinuation: true, + deferFinalizeForBackgroundWork: true, + registerExtensions: (context) => + Effect.sync(() => { + applyMutation = context.applyBackgroundTaskMutation; + }), makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => Effect.void, - environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, - protocolEvents, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: () => Deferred.await(promptGate), + }), }), }, selfInvocation, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }), + }, }); - const threadId = ThreadId.make("thread-acp-stale-deferred-cleanup"); + const threadId = ThreadId.make("thread-acp-wake-report-while-queued"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", + runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), }); const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-stale-deferred-cleanup"), + providerSessionId: ProviderSessionId.make("provider-session-acp-wake-report-while-queued"), modelSelection, runtimePolicy, }); @@ -14015,71 +6368,303 @@ describe("AcpAdapterV2", () => { modelSelection, runtimePolicy, }); - const now = yield* DateTime.now; + const awaitTerminal = (ordinal: number) => + Effect.gen(function* () { + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, `mock-session-1:turn:${ordinal}`), + }); + while (true) { + const event = yield* Queue.take(events); + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { + return event.status; + } + } + }); + yield* runtime + .startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); + assert.equal(yield* awaitTerminal(1), "completed"); + if (applyMutation === undefined || sessionUpdateHandler === undefined) { + return yield* Effect.die("extensions and session handler must be wired"); + } + const endCommand = (taskId: string, label: string) => + applyMutation!({ + sessionId: "mock-session-1", + taskId, + status: "completed", + report: { kind: "command", label }, + }); + const agentText = (text: string) => + sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + + yield* endCommand("task-a", "sleep a"); + yield* agentText("A_DONE"); + assert.lengthOf(continuationRequests, 1); + assert.equal(continuationRequests[0]?.notification?.summary, 'Command "sleep a" finished'); + yield* endCommand("task-b", "sleep b"); + assert.lengthOf(continuationRequests, 1); + yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ); - const oldPending = yield* Queue.take(events).pipe( - Effect.repeat({ - until: (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + messageText: "Background task completed.", }), ); - if ( - oldPending.type !== "runtime_request.updated" || - oldPending.runtimeRequest.providerTurnId === null - ) { - return yield* Effect.die("Expected the old runtime permission request"); - } - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: oldPending.runtimeRequest.providerTurnId, - requestRuntimeRestart: true, + yield* TestClock.adjust("3 seconds"); + assert.equal(yield* awaitTerminal(2), "completed"); + + yield* agentText("B_DONE"); + assert.lengthOf(continuationRequests, 2); + assert.deepEqual(continuationRequests[1]?.notification, { + source: { kind: "command" }, + outcome: "completed", + summary: 'Command "sleep b" finished', }); - const staleResponse = yield* runtime - .respondToRuntimeRequest({ - requestId: oldPending.runtimeRequest.id, - decision: "accept", - }) - .pipe(Effect.exit); - if (Exit.isSuccess(staleResponse)) { - assert.fail("teardown must synchronously remove the old pending request"); - } - while (Option.isSome(yield* Queue.poll(events))) {} + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect.each([ + { status: "completed" as const, exitCode: 0 }, + { status: "failed" as const, exitCode: 1 }, + ])( + "retains early Grok $status command completion across sparse notifications and replayed ACKs", + ({ status, exitCode }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + let taskCompleted: + | ((payload: unknown) => Effect.Effect) + | undefined; + const promptStarted = yield* Deferred.make(); + const promptGate = yield* Deferred.make(); + const promptHeld = yield* Deferred.make(); + const finalizeScheduled = yield* Deferred.make(); + const continuationOffered = yield* Deferred.make(); + const continuationRequests: Array = + []; + const instanceId = ProviderInstanceId.make("acp-test"); + const grokFlavor = makeGrokAcpAdapterFlavor( + { + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation, + }, + yield* HostProcess.HomeDirectory, + ); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation, + testHooks: { + afterPromptSettledWithBackgroundWork: () => + Deferred.succeed(promptHeld, undefined).pipe(Effect.asVoid), + onDeferredFinalizeScheduled: (debounce) => + Deferred.succeed(finalizeScheduled, debounce).pipe(Effect.asVoid), + }, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + enablePostSettleContinuation: true, + deferFinalizeForBackgroundWork: true, + normalizeToolCall: grokFlavor.normalizeToolCall!, + extractBackgroundTaskId: grokFlavor.extractBackgroundTaskId!, + isBackgroundCommandTool: grokFlavor.isBackgroundCommandTool!, + registerExtensions: ({ runtime, applyBackgroundTaskMutation }) => + registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation), + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + handleExtNotification: (method, schema, handler) => + Effect.sync(() => { + if (method === "x.ai/task_completed") { + taskCompleted = (payload) => + handler(Schema.decodeUnknownSync(schema)(payload)); + } + }).pipe(Effect.andThen(runtime.handleExtNotification(method, schema, handler))), + prompt: () => + Deferred.succeed(promptStarted, undefined).pipe( + Effect.andThen(Deferred.await(promptGate)), + ), + }), + }), + }, + continuationRequests: { + offer: (request) => + Effect.sync(() => continuationRequests.push(request)).pipe( + Effect.andThen(Deferred.succeed(continuationOffered, undefined)), + Effect.asVoid, + ), + }, + }); + const threadId = ThreadId.make("thread-acp-early-command"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-early-command"), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(promptStarted); + assert.isDefined(sessionUpdateHandler); + assert.isDefined(taskCompleted); + const update = (update: EffectAcpSchema.SessionUpdate) => + sessionUpdateHandler!({ sessionId: "mock-session-1", update }); + const complete = (taskId: string, snapshot?: { output?: string; exit_code?: number }) => + taskCompleted!({ + sessionId: "mock-session-1", + update: { sessionUpdate: "task_completed", task_id: taskId, task_snapshot: snapshot }, + }); + for (const taskId of ["early", "blocker"]) { + yield* update({ + sessionUpdate: "tool_call", + toolCallId: taskId, + title: `Run ${taskId}`, + kind: "execute", + status: "completed", + rawInput: { command: `echo ${taskId}` }, + rawOutput: { type: "BackgroundTaskStarted", task_id: taskId }, + }); + } + // Progress removes the ACK carrying the task identity before a reportless completion. + yield* update({ + sessionUpdate: "tool_call_update", + toolCallId: "early", + status: "in_progress", + rawOutput: { type: "Text", text: "Command progress" }, + }); + yield* complete("early", { exit_code: exitCode, output: "EARLY_BG_DONE\n" }); + // The real decoder treats this exit-code-free duplicate as completed, even after failure. + yield* complete("early"); + assert.lengthOf(continuationRequests, 0); - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), - ); - let replacementPending: ProviderAdapter.ProviderAdapterV2Event | undefined; - while (replacementPending === undefined) { - const event = yield* Queue.take(events); - if (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") { - replacementPending = event; + let backgroundItem: ProviderAdapter.ProviderAdapterV2Event | undefined; + let checkpoint = 0; + const assertCommand = Effect.fnUntraced(function* (expectedStatus: string, output: string) { + const title = `Checkpoint ${++checkpoint}`; + // A later tool event fences the emitted updates without waiting for a terminal that may never come. + yield* update({ sessionUpdate: "tool_call_update", toolCallId: "blocker", title }); + while (true) { + const event = yield* Queue.take(events); + if (event.type !== "turn_item.updated") continue; + if (event.turnItem.nativeItemRef?.nativeId === "early") backgroundItem = event; + if ( + event.turnItem.nativeItemRef?.nativeId === "blocker" && + event.turnItem.title === title + ) + break; + } + assert.equal(backgroundItem?.type, "turn_item.updated"); + if (backgroundItem?.type !== "turn_item.updated") return; + assert.equal(backgroundItem.turnItem.status, expectedStatus); + assert.equal(backgroundItem.turnItem.type, "command_execution"); + if (backgroundItem.turnItem.type === "command_execution") { + assert.equal(backgroundItem.turnItem.output ?? "", output); + } + }); + yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); + yield* Deferred.await(promptHeld); + yield* assertCommand(status, "EARLY_BG_DONE\n"); + assert.lengthOf(continuationRequests, 0, "the other command still holds the root open"); + + const replayAck = () => + update({ + sessionUpdate: "tool_call_update", + toolCallId: "early", + status: "completed", + rawOutput: { type: "BackgroundTaskStarted", task_id: "early" }, + }); + yield* replayAck(); + yield* assertCommand(status, "EARLY_BG_DONE\n"); + // Sparse ACP updates must retain the command result after projection replaces the ACK. + for (const sparseStatus of ["completed", "in_progress"] as const) { + yield* update({ + sessionUpdate: "tool_call_update", + toolCallId: "early", + status: sparseStatus, + }); + yield* assertCommand(status, "EARLY_BG_DONE\n"); } - } - if (replacementPending.type !== "runtime_request.updated") { - return yield* Effect.die("Expected the replacement runtime permission request"); - } - assert.notEqual(replacementPending.runtimeRequest.id, oldPending.runtimeRequest.id); - yield* runtime.respondToRuntimeRequest({ - requestId: replacementPending.runtimeRequest.id, - decision: "accept", - }); - let replacementTerminal = false; - while (!replacementTerminal) { - const event = yield* Queue.take(events); - if ( - event.type === "runtime_request.updated" && - event.runtimeRequest.id === oldPending.runtimeRequest.id - ) { - assert.fail("stale deferred cleanup must not emit into generation 2"); + if (status === "completed") { + for (const output of ["", "UPDATED_OUTPUT\n"]) { + yield* complete("early", { output }); + yield* replayAck(); + yield* assertCommand(status, output); + } } - replacementTerminal = event.type === "turn.terminal"; - } - }).pipe(Effect.provide(layerTest), Effect.scoped), + yield* complete("blocker", { exit_code: 0, output: "BLOCKER_DONE\n" }); + yield* TestClock.adjust(yield* Deferred.await(finalizeScheduled)); + while (true) { + const event = yield* Queue.take(events); + if (event.type !== "turn.terminal") continue; + assert.equal(event.status, "completed"); + break; + } + yield* Deferred.await(continuationOffered); + yield* complete("early"); + yield* complete("blocker"); + assert.lengthOf( + continuationRequests, + 1, + "duplicate completions must not offer another continuation", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("resolves owner cancellation and concurrent resume waiters after hard teardown", () => + it.live("durably poisons start and resume when required hard teardown is unavailable", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -14089,37 +6674,24 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const teardownStarted = yield* Deferred.make(); - const releaseTeardown = yield* Deferred.make(); - let runtimeOrdinalSeen = 0; const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - interruptPromptOnCancel: true, restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - ownDetachedProcessGroup: true, - processGroupPlatform: "win32", - windowsProcessTreeTerminator: () => - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseTeardown)), - ), - environment: (runtimeOrdinal) => { - runtimeOrdinalSeen = runtimeOrdinal; - return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; - }, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, protocolEvents, }), }, selfInvocation, }); - const threadId = ThreadId.make("thread-acp-concurrent-resume-teardown"); + const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -14128,9 +6700,7 @@ describe("AcpAdapterV2", () => { const modelSelection = { instanceId, model: "default" } as const; const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-concurrent-resume-teardown", - ), + providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"), modelSelection, runtimePolicy, }); @@ -14145,72 +6715,41 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), ) .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* pollProtocolMethods(protocolEvents); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - const interruptFiber = yield* runtime + const interruptExit = yield* runtime .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) - .pipe(Effect.forkScoped); - yield* Deferred.await(teardownStarted); - const resumeFiber = yield* runtime - .resumeThread({ providerThread, modelSelection, runtimePolicy }) - .pipe(Effect.forkScoped); - const secondResumeFiber = yield* runtime + .pipe(Effect.exit); + if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt"); + assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); + + const startExit = yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), + ) + .pipe(Effect.exit); + if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); + assert.include(Cause.pretty(startExit.cause), "session is poisoned"); + const resumeExit = yield* runtime .resumeThread({ providerThread, modelSelection, runtimePolicy }) - .pipe(Effect.forkScoped); - const snapshotProviderThread = { - ...providerThread, - nativeThreadRef: { - ...providerThread.nativeThreadRef!, - nativeId: "mock-session-snapshot", - }, - }; - const snapshotFiber = yield* runtime - .readThreadSnapshot({ providerThread: snapshotProviderThread }) - .pipe(Effect.forkScoped); - const forkFiber = yield* runtime - .forkThread({ - sourceProviderThread: providerThread, - targetThreadId: ThreadId.make("thread-acp-concurrent-fork-after-teardown"), - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isUndefined(resumeFiber.pollUnsafe()); - assert.isUndefined(secondResumeFiber.pollUnsafe()); - assert.isUndefined(snapshotFiber.pollUnsafe()); - assert.isUndefined(forkFiber.pollUnsafe()); - assert.equal(runtimeOrdinalSeen, 1); - const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); - assert.notInclude(methodsDuringTeardown, "session/resume"); - assert.notInclude(methodsDuringTeardown, "session/fork"); - const cancelInterruptOwner = yield* Fiber.interrupt(interruptFiber).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - assert.isUndefined(cancelInterruptOwner.pollUnsafe()); - yield* Deferred.succeed(releaseTeardown, undefined); - yield* Fiber.join(cancelInterruptOwner); - yield* Fiber.join(resumeFiber); - yield* Fiber.join(secondResumeFiber); - yield* Fiber.join(snapshotFiber); - yield* Fiber.join(forkFiber); - assert.equal(runtimeOrdinalSeen, 2); - const methodsAfterRestart = yield* pollProtocolMethods(protocolEvents); - assert.equal(methodsAfterRestart.filter((method) => method === "session/resume").length, 2); - assert.equal(methodsAfterRestart.filter((method) => method === "session/fork").length, 1); + .pipe(Effect.exit); + if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); + assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("direct Stop skips uninterruptible ACP cancel and recovers after native teardown", () => + it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const path = yield* Path.Path; const selfInvocation = yield* resolveSelfInvocation(); @@ -14218,261 +6757,100 @@ describe("AcpAdapterV2", () => { new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const protocolEvents = yield* Queue.bounded(256); - const commandPidPath = yield* fileSystem.makeTempFileScoped({ - prefix: "t3-acp-direct-stop-command-", - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), - ); - let cancelCalled = false; + const teardownStarted = yield* Deferred.make(); + const releaseTeardown = yield* Deferred.make(); + let runtimeOrdinalSeen = 0; const instanceId = ProviderInstanceId.make("acp-test"); const adapter = yield* makeAcpAdapterV2({ instanceId, flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - enablePostSettleContinuation: true, - extractBackgroundTaskId: (toolCall) => - toolCall.toolCallId === "tool-call-running-1" ? "task-running-1" : undefined, - extractBackgroundTaskCompletion: (toolCall) => - toolCall.toolCallId === "tool-call-output-1" - ? [{ taskId: "task-running-1", status: "running", appendOutput: "" }] - : [], interruptPromptOnCancel: true, restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - ownDescendantProcessGroups: true, ownDetachedProcessGroup: true, - environment: (runtimeOrdinal) => - runtimeOrdinal === 1 - ? { - T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", - T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", - T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, - T3_ACP_RUNNING_COMMAND_IGNORE_TERM: "1", - T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1", - } - : {}, + processGroupPlatform: "win32", + windowsProcessTreeTerminator: () => + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseTeardown)), + ), + environment: (runtimeOrdinal) => { + runtimeOrdinalSeen = runtimeOrdinal; + return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; + }, protocolEvents, - wrapCancel: () => - Effect.sync(() => { - cancelCalled = true; - }).pipe(Effect.andThen(Effect.never)), }), }, selfInvocation, - continuationRequests: { offer: () => Effect.void }, }); - const threadId = ThreadId.make("thread-acp-direct-stop-running-command"); + const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-direct-stop-running-command", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - - let runningMonitorSeen = false; - let runningTaskOutputSeen = false; - while (!runningMonitorSeen || !runningTaskOutputSeen) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - (event.turnItem.status === "running" || event.turnItem.status === "pending") - ) { - if (event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1") { - runningMonitorSeen = true; - } - if (event.turnItem.nativeItemRef?.nativeId === "tool-call-output-1") { - runningTaskOutputSeen = true; - } - } - } - const [commandLauncherPid, commandRootPid, commandSleepPid] = Option.getOrThrow( - yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3), - ); - assert.isTrue( - Option.isSome( - yield* waitForProcesses([commandLauncherPid!, commandRootPid!, commandSleepPid!]), - ), - "declared direct Stop launcher, Bash, and sleep PIDs must become live", - ); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* Queue.takeAll(events); - const interruptFiber = yield* runtime - .interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - const interruptCompleted = yield* Fiber.join(interruptFiber).pipe( - Effect.timeoutOption("3 seconds"), - ); - assert.isTrue(Option.isSome(interruptCompleted), "hung ACP cancel must not block teardown"); - assert.isFalse(cancelCalled, "hard process-group teardown must skip ACP cancel"); - yield* Effect.sleep("250 millis"); - assert.isFalse(processExists(commandLauncherPid!)); - assert.isFalse(processExists(commandRootPid!)); - assert.isFalse(processExists(commandSleepPid!)); - - let terminalStatus: string | null = null; - let openToolTerminalStatus: string | null = null; - let openToolInterruptedExitCode: number | undefined = 42; - let lateAfterCancelSeen = false; - let runningMonitorAfterInterrupt = false; - while (terminalStatus === null || openToolTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1" && - event.turnItem.status === "running" - ) { - runningMonitorAfterInterrupt = true; - if (event.turnItem.type === "command_execution") { - assert.equal( - event.turnItem.exitCode, - undefined, - "mid-stream exit_code 0 must not project while the command is still running", - ); - } - } - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - (event.turnItem.status === "failed" || - event.turnItem.status === "cancelled" || - event.turnItem.status === "interrupted" || - event.turnItem.status === "completed") - ) { - openToolTerminalStatus = event.turnItem.status; - openToolInterruptedExitCode = event.turnItem.exitCode; - } - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - terminalStatus = event.status; - } - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes("late after cancel") - ) { - lateAfterCancelSeen = true; - } - } - assert.equal(terminalStatus, "interrupted"); - assert.equal(openToolTerminalStatus, "interrupted"); - assert.equal( - openToolInterruptedExitCode, - undefined, - "interrupted commands must not retain a mid-stream exit_code 0", - ); - assert.isFalse(runningMonitorAfterInterrupt); - assert.isFalse(yield* runtime.hasPendingBackgroundWork!); - assert.isFalse( - lateAfterCancelSeen, - "late post-Stop assistant text must not attach to the stopped run", - ); - - yield* Effect.sleep("200 millis"); + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); yield* Effect.yieldNow; yield* Effect.yieldNow; - let residual = yield* Queue.poll(events); - while (Option.isSome(residual)) { - const event = residual.value; - if ( - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1" - ) { - assert.notEqual(event.turnItem.status, "running"); - } - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes("late after cancel") - ) { - lateAfterCancelSeen = true; - } - residual = yield* Queue.poll(events); - } - assert.isFalse(lateAfterCancelSeen, "quarantine must drop residual stopped-run events"); - - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( - Stream.filter( - (event) => - event.direction === "outgoing" && rawProtocolMethod(event) === "session/resume", - ), - Stream.runHead, - ); - assert.isTrue( - Option.isSome(loadAfterRestart), - "Direct Stop follow-up must respawn the ACP runtime", - ); - - const secondProviderTurnId = idAllocator.derive.providerTurn({ + yield* Effect.yieldNow; + const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - let secondTerminal: string | null = null; - let stoppedRunTextOnFollowUp = false; - while (secondTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes("late after cancel") - ) { - stoppedRunTextOnFollowUp = true; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminal = event.status; - } + yield* pollProtocolMethods(protocolEvents); + const interruptFiber = yield* runtime + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) + .pipe(Effect.forkScoped); + yield* Deferred.await(teardownStarted); + const startFiber = yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), + ) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.isUndefined(startFiber.pollUnsafe()); + assert.equal(runtimeOrdinalSeen, 1); + const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); + assert.notInclude(methodsDuringTeardown, "session/resume"); + assert.notInclude(methodsDuringTeardown, "session/prompt"); + + yield* Deferred.succeed(releaseTeardown, undefined); + yield* Fiber.join(interruptFiber); + yield* Fiber.join(startFiber); + assert.equal(runtimeOrdinalSeen, 2); + const replacementMethods = yield* pollProtocolMethods(protocolEvents); + assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1); + if (!replacementMethods.includes("session/prompt")) { + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); } - assert.equal(secondTerminal, "completed"); - assert.isFalse( - stoppedRunTextOnFollowUp, - "stopped-run residual text must not attach to the follow-up run", - ); }).pipe(Effect.provide(layerTest), Effect.scoped), ); @@ -14537,482 +6915,98 @@ describe("AcpAdapterV2", () => { interactionMode: "default", cwd: process.cwd(), }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-acp-direct-stop-subagent-hold", - ), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: firstProviderTurnId, - requestRuntimeRestart: true, - }); - - let subagentStatus: string | null = null; - let firstTerminalStatus: string | null = null; - while (firstTerminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { - subagentStatus = event.turnItem.status; - } - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminalStatus = event.status; - } - } - assert.equal(firstTerminalStatus, "interrupted"); - assert.equal(subagentStatus, "interrupted"); - - subagentPhase = "complete"; - const secondNow = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: secondNow, - ordinal: 2, - }), - ); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let carriedCompleted = false; - let secondTerminalStatus: string | null = null; - while (secondTerminalStatus === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.status === "completed" - ) { - carriedCompleted = true; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminalStatus = event.status; - } - } - assert.equal(secondTerminalStatus, "completed"); - assert.isFalse( - carriedCompleted, - "Direct Stop must not carry a stopped subagent into the follow-up run", - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("restart_active terminates native work and reloads a clean runtime", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const protocolEvents = yield* Queue.bounded(256); - const commandPidPath = yield* fileSystem.makeTempFileScoped({ - prefix: "t3-acp-restart-active-command-", - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - interruptPromptOnCancel: true, - restartRuntimeAfterInterrupt: true, - restartRuntimeOnEveryInterrupt: true, - terminateRuntimeProcessGroupOnInterrupt: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - ownDescendantProcessGroups: true, - ownDetachedProcessGroup: true, - processGroupTerminationGrace: 0, - environment: (runtimeOrdinal) => - runtimeOrdinal === 1 - ? { - T3_ACP_EXIT_ON_CANCEL: "1", - T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", - T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", - T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, - T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1", - } - : {}, - protocolEvents, - wrapCancel: (cancel) => cancel.pipe(Effect.andThen(Effect.sleep("250 millis"))), - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-restart-active-in-process"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-restart-active-in-process"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime - .startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), - ) - .pipe(Effect.forkScoped); - - let runningToolSeen = false; - while (!runningToolSeen) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - (event.turnItem.status === "running" || event.turnItem.status === "pending") - ) { - runningToolSeen = true; - } - } - const [commandLauncherPid, commandRootPid, commandSleepPid] = Option.getOrThrow( - yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3), - ); - assert.isTrue( - Option.isSome( - yield* waitForProcesses([commandLauncherPid!, commandRootPid!, commandSleepPid!]), - ), - "declared restart_active launcher, Bash, and sleep PIDs must become live", - ); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-direct-stop-subagent-hold", + ), + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), + ); + yield* Deferred.await(promptSettled); - const firstProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - const interruptFiber = yield* runtime - .interruptTurn({ + const firstProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId, - }) - .pipe(Effect.forkScoped); - yield* Fiber.join(interruptFiber); - yield* Effect.sleep("250 millis"); - assert.isFalse(processExists(commandLauncherPid!)); - assert.isFalse(processExists(commandRootPid!)); - assert.isFalse(processExists(commandSleepPid!)); + requestRuntimeRestart: true, + }); - let firstTerminal: string | null = null; - while (firstTerminal === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { - firstTerminal = event.status; + let subagentStatus: string | null = null; + let firstTerminalStatus: string | null = null; + while (firstTerminalStatus === null) { + const event = yield* Queue.take(events); + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { + subagentStatus = event.turnItem.status; + } + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { + firstTerminalStatus = event.status; + } } - } - assert.equal(firstTerminal, "interrupted"); + assert.equal(firstTerminalStatus, "interrupted"); + assert.equal(subagentStatus, "interrupted"); - yield* Queue.takeAll(protocolEvents); - yield* runtime - .startTurn( + subagentPhase = "complete"; + const secondNow = yield* DateTime.now; + yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, - now, + now: secondNow, ordinal: 2, }), - ) - .pipe(Effect.forkScoped); - let loadSeen = false; - let promptSeen = false; - while (!loadSeen || !promptSeen) { - const event = yield* Queue.take(protocolEvents); - if (event.direction !== "outgoing") continue; - const method = rawProtocolMethod(event); - loadSeen ||= method === "session/resume"; - promptSeen ||= method === "session/prompt"; - } - assert.isTrue(loadSeen, "restart_active must replay session/resume on a new ACP process"); - assert.isTrue(promptSeen, "replacement prompt must start after reload"); - const secondProviderTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), - }); - let staleEventSeen = false; - let secondTerminal: string | null = null; - while (secondTerminal === null) { - const event = yield* Queue.take(events); - if ( - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "tool-call-running-1" - ) { - staleEventSeen = true; - } - if ( - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.text.includes("late after cancel") - ) { - staleEventSeen = true; - } - if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { - secondTerminal = event.status; + ); + const secondProviderTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), + }); + let carriedCompleted = false; + let secondTerminalStatus: string | null = null; + while (secondTerminalStatus === null) { + const event = yield* Queue.take(events); + if ( + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status === "completed" + ) { + carriedCompleted = true; + } + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { + secondTerminalStatus = event.status; + } } - } - assert.equal(secondTerminal, "completed"); - assert.isFalse(staleEventSeen, "interrupted runtime events must not attach to attempt 2"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + assert.equal(secondTerminalStatus, "completed"); + assert.isFalse( + carriedCompleted, + "Direct Stop must not carry a stopped subagent into the follow-up run", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); }); -describe("acpPostSettleWakeEvidence", () => { - const sessionId = "session-wake"; - - it("accepts assistant text and tool updates as wake evidence", () => { - assert.isTrue( - acpPostSettleWakeEvidence({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Subagent finished. SUBAGENT_DONE" }, - }, - }), - ); - assert.isTrue( - acpPostSettleWakeEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: "call-1", - title: "get_command_or_subagent_output", - status: "pending", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - }); - - it("rejects monitor end chatter and background mutations", () => { - assert.isFalse( - acpPostSettleWakeEvidence({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: 'Monitor "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ended', - }, - }, - }), - ); - assert.isFalse( - acpPostSettleWakeEvidence( - { - sessionId, - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "task-1 completed" }, - }, - }, - { - extractBackgroundToolMutation: () => [ - { - taskId: "task-1", - status: "completed", - appendOutput: "", - }, - ], - }, - ), - ); - }); -}); - describe("acpPostSettleContinuationOfferEvidence", () => { const sessionId = "session-wake-offer"; - it("offers on assistant text and terminal tool status", () => { - assert.isTrue( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Background shell finished." }, - }, - }), - ); - assert.isTrue( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call-1", - title: "run_terminal_command", - status: "completed", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - assert.isTrue( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call-2", - title: "run_terminal_command", - status: "failed", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - }); - - it("does not offer on thought-only chunks", () => { - assert.isFalse( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "Still reasoning about the monitor output…" }, - }, - }), - ); - assert.isTrue( - acpPostSettleWakeEvidence({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "Still reasoning about the monitor output…" }, - }, - }), - ); - }); - - it("does not offer on whitespace-only assistant chunks", () => { - const notification = { - sessionId, - update: { - sessionUpdate: "agent_message_chunk" as const, - content: { type: "text" as const, text: " \n\t " }, - }, - }; - assert.isFalse(acpPostSettleWakeEvidence(notification)); - assert.isFalse(acpPostSettleContinuationOfferEvidence(notification)); - }); - - it("buffers in-progress tool updates without offering", () => { - assert.isTrue( - acpPostSettleWakeEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call-1", - title: "run_terminal_command", - status: "in_progress", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - assert.isFalse( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call-1", - title: "run_terminal_command", - status: "in_progress", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - assert.isFalse( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: "call-1", - title: "run_terminal_command", - status: "pending", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - }), - ); - }); - - it("does not offer filtered monitor chatter", () => { - assert.isFalse( - acpPostSettleContinuationOfferEvidence({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: 'Monitor "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" ended', - }, - }, - }), - ); - }); - it("does not offer on a normalized monitor start ACK despite raw completed status", () => { const monitorStartAck = { sessionId, @@ -15040,74 +7034,3 @@ describe("acpPostSettleContinuationOfferEvidence", () => { ); }); }); - -describe("acpPostSettleWakeShouldBuffer", () => { - const sessionId = "session-wake-buffer"; - - it("drops agent progress chatter while background work is running", () => { - for (const sessionUpdate of ["agent_message_chunk", "agent_thought_chunk"] as const) { - assert.isFalse( - acpPostSettleWakeShouldBuffer( - { - sessionId, - update: { - sessionUpdate, - content: { type: "text", text: "Still running." }, - }, - }, - true, - ), - ); - } - }); - - it("retains tool state while running and agent output after completion", () => { - const agentMessage = { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Monitor finished successfully." }, - }, - } as const; - const toolUpdate = { - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: "call-monitor", - title: "monitor", - status: "in_progress", - kind: "other", - content: [], - locations: [], - rawInput: {}, - }, - } as const; - - assert.isTrue(acpPostSettleWakeShouldBuffer(toolUpdate, true)); - assert.isTrue(acpPostSettleWakeShouldBuffer(agentMessage, false)); - }); -}); - -describe("acpIsAppOwnedWakeTurn", () => { - it("recognizes only the orchestrator-injected app-owned wake", () => { - assert.isTrue(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "server" })); - assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "provider" })); - assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "web" })); - assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "mcp" })); - assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "server" })); - }); -}); - -describe("acpPostSettleMonitorPromptShouldSuppress", () => { - it("suppresses running monitor prompts but not terminal notices", () => { - assert.isTrue( - acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-active", status: "running" }), - ); - assert.isFalse( - acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-ended", status: "completed" }), - ); - assert.isFalse( - acpPostSettleMonitorPromptShouldSuppress({ taskId: "task-failed", status: "failed" }), - ); - }); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 06e165ada779..fab0020b7e03 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -18,11 +18,9 @@ import * as Fiber from "effect/Fiber"; import * as DateTime from "effect/DateTime"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; @@ -30,7 +28,6 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; -import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { @@ -39,10 +36,7 @@ import { } from "@t3tools/provider-testing/replayGate"; import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ServerSettings from "../../serverSettings.ts"; -import type { - AcpRegistryAvailableCommands, - AcpRegistryLiveConfiguration, -} from "@t3tools/provider-acp-registry/testing"; +import type { AcpRegistryLiveConfiguration } from "@t3tools/provider-acp-registry/testing"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; @@ -55,7 +49,6 @@ import { makeTurnInput, } from "./AcpAdapterV2.testkit.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server"; import { ACP_REGISTRY_PROVIDER, @@ -64,7 +57,6 @@ import { acpRegistryProbeResult, } from "@t3tools/provider-acp-registry/testing"; -const registryUrl = "https://registry.test/registry.json"; const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( AcpRegistryAdapterV2Driver.configSchema, ); @@ -447,20 +439,6 @@ describe("AcpRegistryAdapterV2", () => { assert.notInclude(rejected.message, "private-key"); assert.notInclude(rejected.message, "token=secret"); }); - it("is registered as a generic provider driver with schema defaults", () => { - assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); - assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); - assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { - source: "registry", - commandArgs: [], - enabled: true, - agentId: "", - commandPath: "", - authMethodId: "", - distribution: "auto", - customModels: [], - }); - }); describe("the agent's own pickers", () => { type Frame = Record; @@ -489,7 +467,19 @@ describe("AcpRegistryAdapterV2", () => { readonly storedModePick?: string; readonly turnModels?: ReadonlyArray; }) { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // Scope release gives the replay agent at most a second to exit, so the status + // read below waits for the agent itself instead of racing its last write. + const agentExits: Array> = []; + const childProcessSpawner = ChildProcessSpawner.make((command) => + spawner + .spawn(command) + .pipe( + Effect.tap((handle) => + Effect.sync(() => agentExits.push(Effect.ignore(handle.exitCode))), + ), + ), + ); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const replayDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -614,6 +604,7 @@ describe("AcpRegistryAdapterV2", () => { current = target; } }).pipe(Effect.scoped); + yield* Effect.all(agentExits, { discard: true }); yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); return { configurations }; }); @@ -779,6 +770,7 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #19519 models come from session model state, not a hardcoded list it.effect("leaves unadvertised model IDs alone in both model selection protocols", () => Effect.gen(function* () { const { configurations } = yield* openWithStoredPicks({ @@ -918,86 +910,9 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(opened.configurations.at(-1)?.currentModelId, "codex"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - - it.effect("ignores a later-option model without writing configuration", () => - Effect.gen(function* () { - const { configurations } = yield* openWithStoredPicks({ - setup: { configOptions: modelOptions }, - pickFrames: [], - storedModel: "later-model", - }); - assert.equal(configurations[0]?.currentModelId, "gateway"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); }); - it.effect("offers client terminals to Devin only and client fs to no registry agent", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const advertisedCapabilities = Effect.fn("advertisedCapabilities")(function* ( - agentId: string, - ) { - let clientCapabilities: unknown; - const instanceId = ProviderInstanceId.make(`acp-registry-capabilities-${agentId}`); - const adapter = yield* makeAcpRegistryAdapterV2({ - selfInvocation: yield* resolveSelfInvocation(), - instanceId, - settings: yield* decodeAcpRegistryAdapterSettings({ agentId, authMethodId: "test" }), - environment: {}, - makeRuntime: (input) => - Effect.gen(function* () { - clientCapabilities = input.clientCapabilities; - const { processEnvironment: _processEnvironment, ...runtimeInput } = input; - const context = yield* Layer.build( - AcpSessionRuntime.layer({ - ...runtimeInput, - spawn: { - command: process.execPath, - args: [mockAgentPath], - cwd: input.cwd, - env: { T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - authMethodId: "test", - }).pipe( - Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - ), - ); - return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( - Effect.provide(context), - ); - }), - }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - yield* adapter.openSession({ - threadId: ThreadId.make(`thread-acp-registry-capabilities-${agentId}`), - providerSessionId: ProviderSessionId.make(`provider-session-capabilities-${agentId}`), - modelSelection: { instanceId, model: "default" }, - runtimePolicy, - }); - return clientCapabilities; - }); - - assert.deepInclude(yield* advertisedCapabilities("devin"), { - fs: { readTextFile: false, writeTextFile: false }, - terminal: true, - }); - assert.deepInclude(yield* advertisedCapabilities("gemini"), { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - + // regression: #21618 Claude Agent needs ACP v2 for native background lifecycle it.effect("launches Claude Agent on ACP v2 unless its instance environment decides", () => Effect.gen(function* () { const launchSwitch = Effect.fn("launchSwitch")(function* ( @@ -1926,147 +1841,4 @@ describe("AcpRegistryAdapterV2", () => { assert.isFalse(yield* queued.runtime.hasPendingBackgroundWork!); }).pipe(Effect.provide(layerTest), Effect.scoped), ); - - it.effect("opens a real ACP child process resolved from registry configuration", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const host = yield* ProviderHost.ProviderHost; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ - cacheDir: host.paths.providerStatusCacheDir, - toolsDir: host.paths.baseDir + "/tools", - registryUrl, - }); - const settings = yield* decodeAcpRegistryAdapterSettings({ - agentId: "fixture-agent", - commandPath: process.execPath, - authMethodId: "test", - }); - let startupActive = false; - let startupCount = 0; - const instanceId = ProviderInstanceId.make("acp-registry-fixture"); - const commandsPublished = yield* Deferred.make<{ - readonly instanceId: ProviderInstanceId; - readonly commands: AcpRegistryAvailableCommands; - }>(); - const configurationPublished = yield* Deferred.make(); - const adapter = yield* makeAcpRegistryAdapterV2({ - selfInvocation: yield* resolveSelfInvocation(), - instanceId, - settings, - environment: { - T3_ACP_SESSION_LIFECYCLE: "1", - T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "750", - }, - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({ - withForegroundStartup: (agentId, effect) => - Effect.acquireUseRelease( - Effect.sync(() => { - assert.equal(agentId, "fixture-agent"); - startupActive = true; - startupCount += 1; - }), - () => effect, - () => - Effect.sync(() => { - startupActive = false; - }), - ), - runBackgroundProbe: (_agentId, effect) => effect.pipe(Effect.map(Option.some)), - withSessionMutation: (effect) => effect, - clearAvailableCommands: () => Effect.void, - publishAvailableCommands: (publishedInstanceId, commands) => - Deferred.succeed(commandsPublished, { - instanceId: publishedInstanceId, - commands, - }).pipe(Effect.asVoid), - getAvailableCommands: () => Effect.succeed(Option.none()), - watchAvailableCommands: () => Effect.never, - clearLiveConfiguration: () => Effect.void, - publishLiveConfiguration: (_publishedInstanceId, configuration) => - Deferred.succeed(configurationPublished, configuration).pipe(Effect.asVoid), - getLiveConfiguration: () => Effect.succeed(Option.none()), - watchLiveConfiguration: () => Effect.never, - requestUrlAuthentication: () => Effect.succeed(false), - acceptUrlAuthentication: () => Effect.succeed(false), - getUrlAuthAction: () => Effect.succeed(Option.none()), - watchUrlAuthAction: () => Effect.never, - }), - Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ - resolve: (configuredSettings, cwd, environment) => - Effect.sync(() => assert.isTrue(startupActive)).pipe( - Effect.andThen(resolver.resolve(configuredSettings, cwd, environment)), - Effect.map((resolved) => ({ - ...resolved, - spawn: { - ...resolved.spawn, - args: [mockAgentPath], - }, - })), - ), - }), - ), - ), - ); - const threadId = ThreadId.make("thread-acp-registry-fixture"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-registry-fixture"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - - assert.equal(runtime.providerSession.driver, "acpRegistry"); - assert.equal(startupCount, 1); - assert.isFalse(startupActive); - assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); - assert.equal(providerThread.nativeMetadata?.itemIdentityVersion, 2); - assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); - assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); - assert.deepEqual(yield* Deferred.await(commandsPublished), { - instanceId, - commands: { - slashCommands: [ - { - name: "review", - description: "Review the current changes", - input: { hint: "focus" }, - }, - ], - skills: [ - { - name: "workspace-skill", - description: "Run the workspace skill", - path: "acp://skill/workspace-skill", - scope: "agent", - enabled: true, - }, - ], - }, - }); - const configuration = yield* Deferred.await(configurationPublished); - assert.equal(configuration.currentModelId, "default"); - assert.deepInclude(configuration.models[0], { - id: "default", - name: "Auto", - description: null, - }); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 70c07960ac64..ffd8bc54faee 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -74,25 +74,6 @@ describe("AntigravityAdapterV2 flavor", () => { assert.isTrue(flavor.subagentsIdleOnTurnCompletion); }); - it("maps runtime modes to the agent's native permission modes", () => { - const mode = (runtimeMode: "approval-required" | "auto-accept-edits" | "full-access") => - flavor.sessionModeForPolicy?.( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode, - interactionMode: "default", - cwd: "/workspace", - }), - undefined, - ); - assert.equal(mode("approval-required"), "default"); - assert.equal(mode("auto-accept-edits"), "auto_edit"); - assert.equal(mode("full-access"), "yolo"); - }); - - it("exposes Antigravity's /compact command through the v2 compaction path", () => { - assert.isTrue(flavor.supportsCompaction); - }); - it("routes interaction_* permission requests to the question card", () => { const question = flavor.extractPermissionQuestion?.( permissionRequest("interaction_1", [ @@ -115,19 +96,6 @@ describe("AntigravityAdapterV2 flavor", () => { ), ); }); - - it("advertises only the approval decisions the request can honor", () => { - const options = flavor.approvalOptions?.( - permissionRequest("tool_1", [ - { optionId: "once", name: "Allow", kind: "allow_once" }, - { optionId: "deny", name: "Deny", kind: "reject_once" }, - ]), - ); - assert.deepEqual( - options?.map((option) => option.decision), - ["accept", "decline", "cancel"], - ); - }); }); const layerSession = Layer.mergeAll( @@ -183,9 +151,11 @@ describe("AntigravityAdapterV2 client file system", () => { withProcess: (_stop, task) => task, defaultModel: Effect.succeed(undefined), }); - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-antigravity-workspace-", - }); + const workspace = yield* fileSystem + .makeTempDirectoryScoped({ + prefix: "t3-antigravity-workspace-", + }) + .pipe(Effect.flatMap(fileSystem.realPath)); const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-antigravity-outside-", }); @@ -409,111 +379,6 @@ describe("AntigravityAdapterV2 workspace changes", () => { ); }); -describe("AntigravityAdapterV2 client file system under restrictive policies", () => { - it.effect("serves in-workspace reads and writes and still refuses outside paths", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; - const policies = [ - { - name: "read-only sandbox", - policy: { - runtimeMode: "full-access", - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly" }, - }, - }, - { name: "approval-required", policy: { runtimeMode: "approval-required" } }, - ] as const; - for (const { name, policy } of policies) { - let readTextFile: Parameters[0] | undefined; - let writeTextFile: Parameters[0] | undefined; - const instanceId = ProviderInstanceId.make(`antigravity-restrictive-${policy.runtimeMode}`); - const adapter = yield* makeAntigravityAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - makeRuntime: (input) => - makeAntigravityAcpRuntime({ - ...input, - childProcessSpawner, - spawn: { - command: process.execPath, - args: [mockAgentPath], - cwd: input.cwd, - env: { T3_ACP_ANTIGRAVITY: "1" }, - }, - }).pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((runtime): RuntimeService => ({ - ...runtime, - handleReadTextFile: (handler) => - Effect.sync(() => { - readTextFile = handler; - }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))), - handleWriteTextFile: (handler) => - Effect.sync(() => { - writeTextFile = handler; - }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))), - })), - ), - withProcess: (_stop, task) => task, - defaultModel: Effect.succeed(undefined), - }); - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-ag-restrictive-", - }); - const outside = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-ag-restrictive-outside-", - }); - yield* fileSystem.writeFileString(path.join(workspace, "existing.ts"), "existing"); - const threadId = ThreadId.make(`thread-antigravity-restrictive-${policy.runtimeMode}`); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - ...policy, - interactionMode: "default", - cwd: workspace, - }); - const modelSelection = { instanceId, model: "gemini-test-low" } as const; - const session = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - `provider-session-antigravity-restrictive-${policy.runtimeMode}`, - ), - modelSelection, - runtimePolicy, - }); - yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); - if (readTextFile === undefined || writeTextFile === undefined) { - return yield* Effect.die("Antigravity sessions must serve client file requests"); - } - const context = (method: string) => ({ requestId: `test-${method}`, method }); - const read = yield* readTextFile( - { sessionId: "mock-session-1", path: path.join(workspace, "existing.ts") }, - context("fs/read_text_file"), - ); - assert.equal(read.content, "existing", name); - const written = path.join(workspace, "src", "edited.ts"); - yield* writeTextFile( - { sessionId: "mock-session-1", path: written, content: "edited" }, - context("fs/write_text_file"), - ); - assert.equal(yield* fileSystem.readFileString(written), "edited", name); - const outsideWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: path.join(outside, "x.ts"), content: "x" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(outsideWrite), name); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "x.ts")), name); - } - }).pipe(Effect.provide(layerSession), Effect.scoped), - ); -}); - describe("AntigravityAdapterV2 html_render pages", () => { it.effect("shows the page html_render published when the agent reports only a summary", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a232f16b08ad..a4f621a85617 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,7 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams import * as NodeEvents from "node:events"; import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeStream from "node:stream"; @@ -37,7 +36,6 @@ import { NodeId, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, - OrchestrationV2UserInputQuestion, ProjectId, ProviderInstanceId, type ProviderInstanceEnvironment, @@ -51,7 +49,6 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -63,10 +60,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Scheduler from "effect/Scheduler"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -119,9 +116,6 @@ beforeEach(() => { sources: [], }); }); -const decodeUserInputQuestions = Schema.decodeUnknownSync( - Schema.Array(OrchestrationV2UserInputQuestion), -); const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ @@ -238,47 +232,6 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { - it.each([false, true])("requests thinking summaries with resume=%s", (resume) => { - const options = ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - nativeThreadId: "thinking-thread", - resume, - cwd: "/workspace", - }); - assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); - assert.equal(options.extraArgs?.["thinking-display"], "summarized"); - assert.include(options.settings, { showThinkingSummaries: true }); - }); - - it("preserves an explicit omitted thinking display", () => { - const options = ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - nativeThreadId: "thinking-thread", - resume: false, - cwd: "/workspace", - settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs: "--thinking-display omitted" }, - }); - assert.isUndefined(options.thinking); - assert.equal(options.extraArgs?.["thinking-display"], "omitted"); - assert.notInclude(options.settings ?? {}, { showThinkingSummaries: true }); - }); - - it("does not enable thinking when the model option disables it", () => { - const options = ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5", - options: [{ id: "thinking", value: false }], - }, - nativeThreadId: "thinking-thread", - resume: false, - cwd: "/workspace", - }); - assert.isUndefined(options.thinking); - assert.isUndefined(options.extraArgs?.["thinking-display"]); - assert.include(options.settings, { alwaysThinkingEnabled: false }); - }); - it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], @@ -313,26 +266,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.include(systemPrompt.append ?? "", "a `cd` does not carry over"); }); - it("passes automatic compaction and resume-dialog controls to the SDK", () => { - const onUserDialog = async () => ({ - behavior: "completed" as const, - result: "continue" as const, - }); - const options = ClaudeAdapterV2.makeClaudeQueryOptions({ - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - nativeThreadId: "native-thread-auto-compact", - resume: true, - cwd: "/workspace", - settings: AUTO_COMPACT_CLAUDE_SETTINGS, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }); - - assert.equal((options.settings as { autoCompactWindow?: number }).autoCompactWindow, 300_000); - assert.equal(options.onUserDialog, onUserDialog); - assert.deepEqual(options.supportedDialogKinds, ["resume_return"]); - }); - it("projects AskUserQuestion input with question text as the answer key", () => { const preview = "\n **Proposed change**\n\n```ts\n\tconst enabled = true;\n```\n "; assert.deepEqual( @@ -376,52 +309,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ); }); - it("fills blank AskUserQuestion descriptions from trimmed labels", () => { - const questions = decodeUserInputQuestions( - ClaudeAdapterV2.claudeUserInputQuestions({ - questions: [ - { - header: "Naming", - question: "Which name?", - options: [ - { label: "Recorder", description: " Matches the package name. " }, - { label: " Keep ", description: "" }, - { label: " Other ", description: " \t\n " }, - { label: " Ask later " }, - ], - }, - ], - }), - ); - assert.deepEqual(questions[0]?.options, [ - { label: "Recorder", description: "Matches the package name." }, - { label: "Keep", description: "Keep" }, - { label: "Other", description: "Other" }, - { label: "Ask later", description: "Ask later" }, - ]); - }); - - it("normalizes Claude todo and proposed-plan tool input", () => { - assert.deepEqual( - ClaudeAdapterV2.claudeTodoSteps({ - todos: [ - { content: "Inspect", status: "completed" }, - { content: "Implement", status: "in_progress" }, - ], - }), - [ - { id: "todo-0", text: "Inspect", status: "completed" }, - { id: "todo-1", text: "Implement", status: "running" }, - ], - ); - assert.equal( - ClaudeAdapterV2.claudeProposedPlan({ plan: " # Plan\nShip it " }), - "# Plan\nShip it", - ); - assert.isTrue(ClaudeAdapterV2.ClaudeProviderCapabilitiesV2.planning.emitsTodoList); - assert.isTrue(ClaudeAdapterV2.ClaudeProviderCapabilitiesV2.planning.emitsProposedPlan); - }); - it("maps canonical read-only never policy to Claude dontAsk with read-only tools", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -445,29 +332,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { }); }); - it("maps canonical read-only on-request policy to Claude default with callbacks", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - approvalPolicy: "on-request", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "default", - tools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS, - allowedTools: ClaudeAdapterV2.CLAUDE_READ_ONLY_ALLOWED_TOOLS, - installPermissionCallback: true, - }); - }); - it("does not auto-allow reads for canonical restricted read-only never policy", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -534,21 +398,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ); }); - it("maps Auto runtime mode to Claude's AI-reviewed permission mode", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto", - interactionMode: "default", - cwd: "/workspace", - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "auto", - installPermissionCallback: false, - }); - }); - it("keeps approval-required mode interactive with danger-full-access sandboxing", () => { const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -566,60 +415,6 @@ describe("ClaudeAdapterV2 runtime query policy", () => { installPermissionCallback: true, }); }); - - it("installs the permission callback for approval-required plan mode", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "plan", - cwd: "/workspace", - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "plan", - installPermissionCallback: true, - }); - }); - - it("honors never approvals for approval-required workspace-write policy", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: "/workspace", - approvalPolicy: "never", - sandboxPolicy: { - type: "workspaceWrite", - }, - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "dontAsk", - installPermissionCallback: false, - }); - }); - - it("honors never approvals for externally sandboxed policy", () => { - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: "/workspace", - approvalPolicy: "never", - sandboxPolicy: { - type: "externalSandbox", - }, - }), - ); - - assert.deepEqual(queryPolicy, { - permissionMode: "bypassPermissions", - allowDangerouslySkipPermissions: true, - installPermissionCallback: false, - }); - }); }); describe("ClaudeAdapterV2 MCP query overrides", () => { @@ -647,54 +442,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { browserToolsAvailable: true, }); - it("leaves an absent allowlist absent when no MCP session exists", () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - mcpSession: undefined, - readOnlySandbox: false, - }); - - assert.deepEqual(overrides, {}); - }); - - it("preserves an explicit allowlist when no MCP session exists", () => { - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - mcpSession: undefined, - readOnlySandbox: false, - allowedTools: ["Read"], - }); - - assert.deepEqual(overrides, { allowedTools: ["Read"] }); - }); - - it("pre-approves all t3-code tools when attaching an MCP session without an allowlist", () => { - const threadId = ThreadId.make("thread-claude-mcp-no-allowlist"); - const mcpSession = mcpSessionFor(threadId); - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - mcpSession, - readOnlySandbox: false, - }); - - assert.deepEqual(overrides, { - allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD], - mcpServers: T3_MCP_SERVERS, - }); - }); - - it("extends an explicit allowlist with the t3-code wildcard", () => { - const threadId = ThreadId.make("thread-claude-mcp-with-allowlist"); - const mcpSession = mcpSessionFor(threadId); - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - mcpSession, - readOnlySandbox: false, - allowedTools: ["Read", "mcp__t3-code__*"], - }); - - assert.deepEqual(overrides, { - allowedTools: ["Read", "mcp__t3-code__*"], - mcpServers: T3_MCP_SERVERS, - }); - }); - it("pre-approves only read-only t3-code tools in a read-only sandbox", () => { const threadId = ThreadId.make("thread-claude-mcp-read-only"); const mcpSession = mcpSessionFor(threadId); @@ -714,50 +461,6 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { assert.isFalse(overrides.allowedTools?.includes(ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD)); }); - it("pre-approves only read-only t3-code tools in a read-only sandbox without an allowlist", () => { - const threadId = ThreadId.make("thread-claude-mcp-read-only-no-allowlist"); - const mcpSession = mcpSessionFor(threadId); - const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({ - mcpSession, - readOnlySandbox: true, - }); - - assert.deepEqual(overrides.allowedTools, [ - ...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS, - ]); - }); - - it("keys live-query reuse on the MCP-derived pre-approvals", () => { - const threadId = ThreadId.make("thread-claude-mcp-query-key"); - const mcpSession = mcpSessionFor(threadId); - const queryPolicy = ClaudeAdapterV2.claudeRuntimeQueryPolicyForRuntimePolicy( - ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - approvalPolicy: "on-request", - sandboxPolicy: { - type: "readOnly", - access: { type: "fullAccess" }, - networkAccess: false, - }, - }), - ); - - const readOnlyKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: true }), - ); - const fullAccessKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey( - queryPolicy, - ClaudeAdapterV2.claudeMcpQueryOverrides({ mcpSession, readOnlySandbox: false }), - ); - const detachedKey = ClaudeAdapterV2.claudeEffectiveQueryPolicyKey(queryPolicy, {}); - - assert.notEqual(readOnlyKey, fullAccessKey); - assert.notEqual(fullAccessKey, detachedKey); - }); - it("invalidates live-query reuse when MCP credentials rotate", () => { const threadId = ThreadId.make("thread-claude-mcp-credential-rotation"); const mcpSession = mcpSessionFor(threadId); @@ -861,191 +564,27 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); }); - - it.effect("writes Claude Agent SDK protocol frames to the native provider log", () => - Effect.gen(function* () { - const writes: Array<{ - readonly event: unknown; - readonly threadId: ThreadId | null; - }> = []; - const logger: ProviderEventLoggers.EventNdjsonLogger = { - filePath: "/tmp/events.log", - write: (event, threadId) => - Effect.sync(() => { - writes.push({ event, threadId }); - }), - close: () => Effect.void, - }; - const threadId = ThreadId.make("thread-1"); - const providerSessionId = ProviderSessionId.make("provider-session-1"); - const protocolLogger = ClaudeAdapterV2.makeClaudeAgentSdkProtocolLogger({ - nativeEventLogger: logger, - threadId, - providerSessionId, - }); - - assert.notEqual(protocolLogger, undefined); - if (protocolLogger === undefined) { - return; - } - - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: { - type: "stream_event", - uuid: "00000000-0000-0000-0000-000000000001", - session_id: "native-thread", - parent_tool_use_id: null, - event: { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - get text(): string { - throw new Error("streaming text must not be inspected"); - }, - }, - }, - }, - }); - yield* protocolLogger({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.interrupt", - }, - }); - - assert.equal(writes.length, 1); - assert.equal(writes[0]?.threadId, threadId); - assert.deepEqual(writes[0]?.event, { - provider: "claudeAgent", - protocol: ClaudeAdapterV2.CLAUDE_AGENT_SDK_QUERY_PROTOCOL, - kind: "protocol", - providerSessionId, - event: { - direction: "outgoing", - stage: "decoded", - payload: { - type: "query.interrupt", - }, - }, - }); - }), - ); - - it("logs query options without leaking environment values or callback functions", () => { - const options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions = { - model: "claude-sonnet-4-6", - tools: { - type: "preset", - preset: "claude_code", - }, - permissionMode: "default", - sessionId: "native-thread-1", - cwd: "/workspace", - env: { - ANTHROPIC_API_KEY: "secret", - }, - extraArgs: { - "append-system-prompt": "secret launch prompt", - }, - canUseTool: (_toolName, input, callbackOptions) => - Promise.resolve({ - behavior: "allow", - updatedInput: input, - toolUseID: callbackOptions.toolUseID, - decisionClassification: "user_temporary", - }), - }; - - assert.deepEqual(ClaudeAdapterV2.loggedClaudeQueryOptions(options), { - model: "claude-sonnet-4-6", - tools: { - type: "preset", - preset: "claude_code", - }, - permissionMode: "default", - sessionId: "native-thread-1", - cwd: "/workspace", - hasCanUseTool: true, - hasEnvironment: true, - hasExtraArgs: true, - }); - assert.notInclude( - JSON.stringify(ClaudeAdapterV2.loggedClaudeQueryOptions(options)), - "secret launch prompt", - ); - }); }); describe("ClaudeAdapterV2 context usage", () => { - it("projects assistant usage against the selected context window", () => { + it("dates cache expiry from the request start, not the response end", () => { const usage = ClaudeAdapterV2.claudeProviderTurnTokenUsage( { - input_tokens: 42_000, - cache_creation_input_tokens: 2_000, - cache_read_input_tokens: 5_000, - output_tokens: 1_000, + input_tokens: 1, + output_tokens: 1, + cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }, }, CLAUDE_TEST_MODEL_SELECTION, - "2026-08-29T00:00:00.000Z", + "2026-08-29T00:03:00.000Z", + "", + DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), ); - - assert.deepEqual(usage, { - usedTokens: 50_000, - maxTokens: 200_000, - inputTokens: 49_000, - cachedInputTokens: 5_000, - outputTokens: 1_000, - reasoningOutputTokens: 0, - updatedAt: "2026-08-29T00:00:00.000Z", - }); + assert.strictEqual(usage.promptCacheExpiresAt, "2026-08-29T00:05:00.000Z"); }); - - it("reports the prompt cache TTL the response wrote", () => { - const ttlFor = (cache_creation: { - ephemeral_1h_input_tokens: number; - ephemeral_5m_input_tokens: number; - }) => - ClaudeAdapterV2.claudeProviderTurnTokenUsage( - { input_tokens: 1, output_tokens: 1, cache_creation }, - CLAUDE_TEST_MODEL_SELECTION, - "2026-08-29T00:00:00.000Z", - ).promptCacheTtlMs; - - assert.strictEqual( - ttlFor({ ephemeral_1h_input_tokens: 2_000, ephemeral_5m_input_tokens: 300 }), - 60 * 60_000, - ); - assert.strictEqual( - ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }), - 5 * 60_000, - ); - assert.strictEqual( - ttlFor({ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 }), - undefined, - ); - }); - - it("dates cache expiry from the request start, not the response end", () => { - const usage = ClaudeAdapterV2.claudeProviderTurnTokenUsage( - { - input_tokens: 1, - output_tokens: 1, - cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 300 }, - }, - CLAUDE_TEST_MODEL_SELECTION, - "2026-08-29T00:03:00.000Z", - "", - DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"), - ); - assert.strictEqual(usage.promptCacheExpiresAt, "2026-08-29T00:05:00.000Z"); - }); -}); +}); describe("ClaudeAdapterV2 session permissions", () => { + // regression: #15892 an explicit user refusal must stay user_reject, not a tool failure it("keeps explicit user refusals classified as user_reject", () => { const result = ClaudeAdapterV2.permissionResultFromDecision({ toolName: "Bash", @@ -1327,6 +866,7 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { ), ); + // regression: #20413 malformed append-system-prompt launch args fail before a query opens it.effect.each([ [ "--append-system-prompt-file missing.md", @@ -1393,28 +933,6 @@ describe("ClaudeAdapterV2 approval cancellation", () => { assert.equal(result, "cancel"); }), ); - - it.effect("removes the cancellation listener after approval resolves", () => - Effect.gen(function* () { - const decision = yield* Deferred.make(); - const controller = new AbortController(); - let removes = 0; - const removeEventListener = controller.signal.removeEventListener.bind(controller.signal); - controller.signal.removeEventListener = (...args) => { - removes += 1; - return removeEventListener(...args); - }; - const fiber = yield* Effect.forkChild( - ClaudeAdapterV2.awaitClaudeApprovalDecision(decision, controller.signal), - ); - yield* Effect.yieldNow; - yield* Deferred.succeed(decision, "accept"); - const result = yield* Fiber.join(fiber); - - assert.equal(result, "accept"); - assert.equal(removes, 1); - }), - ); }); const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( @@ -1487,40 +1005,6 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio }); describe("ClaudeAdapterV2 executable path", () => { - it.effect.each([ - { source: "the default", environment: [], hostEnvironment: {}, expected: undefined }, - { - source: "an instance opt-in", - environment: [{ name: "CLAUDE_CODE_ENABLE_TODO_TOOLS", value: "1", sensitive: false }], - hostEnvironment: {}, - expected: "1", - }, - { - source: "a host opt-in", - environment: [], - hostEnvironment: { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" }, - expected: "1", - }, - ])("expands the executable path and respects $source for task tools", (testCase) => - Effect.scoped( - Effect.gen(function* () { - const path = yield* Path.Path; - const { executablePaths, taskToolEnvironment } = yield* captureSdkExecutablePaths( - "~/bin/claude", - testCase.environment, - ); - - assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); - assert.deepEqual(taskToolEnvironment, [testCase.expected]); - }), - ).pipe( - Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("follows a bare claude on Windows to the npm package executable", () => Effect.scoped( Effect.gen(function* () { @@ -2027,95 +1511,6 @@ describe("ClaudeAdapterV2 attachments", () => { ), ), ); - - it.effect("rejects unsupported image types before opening a provider query", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-unsupported-attachment-", - }); - let openCount = 0; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"), - open: () => - Effect.sync(() => { - openCount += 1; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-unsupported-attachment"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-unsupported-attachment", - ), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const attachment = ChatImageAttachment.make({ - type: "image", - id: ChatAttachmentId.make( - "thread-claude-unsupported-12345678-1234-1234-1234-123456789abc", - ), - name: "diagram.svg", - mimeType: "image/svg+xml", - sizeBytes: 4, - }); - const now = yield* DateTime.now; - - const error = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-unsupported-attachment"), - text: "Inspect this image.", - attachments: [attachment], - }), - ) - .pipe(Effect.flip); - - assert.equal(error._tag, "ProviderAdapterTurnStartError"); - assert.include(String(error.cause), "Unsupported Claude image attachment type"); - assert.equal(openCount, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); }); describe("ClaudeAdapterV2 native fork", () => { @@ -2398,18 +1793,6 @@ describe("ClaudeAdapterV2 MCP credential channel", () => { assert.notInclude(spawned, "dummy-mcp-credential"); }), ); - - it.effect("closes the CLI when it never answers the MCP registration", () => - Effect.gen(function* () { - const cli = makeFakeClaudeCli(false); - const opening = yield* openWith(cli).pipe(Effect.result, Effect.forkChild); - yield* TestClock.adjust("90 seconds"); - const opened = yield* Fiber.join(opening); - - assert.equal(opened._tag, "Failure"); - assert.isTrue(cli.closed()); - }), - ); }); describe("ClaudeAdapterV2 native session identity", () => { @@ -2487,15 +1870,6 @@ describe("ClaudeAdapterV2 native session identity", () => { ), ); - it.effect("creates the native session on the first provider turn", () => - Effect.gen(function* () { - const openedQueries = yield* openTurnWithOrdinal(1); - assert.equal(openedQueries.length, 1); - assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity"); - assert.equal(openedQueries[0]?.options.resume, undefined); - }), - ); - it.effect( "resumes the native session on a fresh session instance when prior provider turns exist", () => @@ -2831,15 +2205,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReason: null, }); - const awaitUntil = (predicate: () => boolean, label: string): Effect.Effect => + // Harnesses notify after every recorded event, continuation, or prompt, so a + // wait re-checks exactly when the state it reads changes. Subscribing before + // the first check means a change that lands in between is not lost. + const makeChangeSignal = Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const until = (predicate: () => boolean): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + while (!predicate()) yield* PubSub.take(subscription); + }), + ); + return { notify: PubSub.publish(changes, undefined), until }; + }); + + // Fixed quiet windows for asserting that nothing happens after input the + // harness cannot observe being handled (a shut-down query, a replaced process). + const yieldTicks = (ticks: number): Effect.Effect => Effect.gen(function* () { - for (let attempt = 0; attempt < 5000; attempt++) { - if (predicate()) { - return; - } - yield* Effect.yieldNow; - } - return yield* Effect.die(`Timed out waiting for ${label}.`); + for (let tick = 0; tick < ticks; tick++) yield* Effect.yieldNow; }); const makeWakeHarnessWithOptions = (options?: { @@ -2884,6 +2269,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (offered !== undefined) yield* Deferred.succeed(offered, undefined); yield* Deferred.await(processed); }); + const changes = yield* makeChangeSignal; const offeredMessages: Array = []; const permissionModeChanges: Array = []; const liveSelectionChanges: Array = []; @@ -2948,7 +2334,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))), + }).pipe( + Effect.andThen(Queue.offer(continuationReceipts, request)), + Effect.andThen(changes.notify), + ), }, queryRunner: { allocateSessionId: @@ -2984,7 +2373,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (message: SDKUserMessage) => Effect.sync(() => { offeredMessages.push(message); - }), + }).pipe(Effect.andThen(changes.notify)), setModel: () => Effect.void, applyFlagSettings: () => Effect.void, setPermissionMode: (mode) => @@ -3005,7 +2394,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (message) => Effect.sync(() => { offeredMessages.push(message); - }), + }).pipe(Effect.andThen(changes.notify)), setModel: (model) => Effect.sync(() => { liveSelectionChanges.push({ model }); @@ -3061,6 +2450,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } + yield* changes.notify; }), ), Effect.forkScoped, @@ -3103,6 +2493,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, + until: changes.until, + awaitTerminal: (count: number) => changes.until(() => terminalEvents().length >= count), + awaitContinuation: (count: number) => + changes.until(() => continuationRequests.length >= count), }; }); const makeWakeHarness = makeWakeHarnessWithOptions(); @@ -3429,6 +2823,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #21806 bypass policy reads the custom home and project, never ambient ~/.claude it.effect( "combines a normalized custom home with project policy without reading the ambient user tier", () => @@ -3477,6 +2872,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #21806 only a missing custom settings file is benign; bad policy fails it.effect( "distinguishes missing custom settings from unreadable, malformed, or unresolved policy", () => @@ -3785,6 +3181,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #15475 MCP title arriving after the tool starts must survive to completion it.effect.each([ { isError: false, title: "Check weather" }, { isError: true, title: "Check weather" }, @@ -4110,85 +3507,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - const plan = "Planning the check"; - const update = "Found it: the memory file is the cause."; - it.effect.each([ - { - model: "claude-opus-5-5", - texts: [plan, update], - expected: ["reasoning", "assistant_message"], - }, - { - model: "claude-fable-5-1", - texts: [plan, update], - expected: ["reasoning", "assistant_message"], - }, - { model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] }, - { model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] }, - { model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] }, - { model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] }, - { model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] }, - ] as const)( - "shows $model thinking $texts before a tool call as $expected", - ({ model, texts, expected }) => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("thinking-before-tool-attempt"), - text: "Check the memory file", - attachments: [], - }), - ); - const snapshot = (uuid: string, content: ReadonlyArray) => - claudeSdkFrame({ - type: "assistant", - uuid, - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { id: "reply-message", model, content }, - }); - const frames = [ - ...texts.map((thinking, index) => - snapshot(`thinking-snapshot-${index}`, [ - { type: "thinking", thinking, signature: "sig" }, - ]), - ), - snapshot("tool-snapshot", [ - { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } }, - ]), - makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }), - ]; - for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); - yield* Queue.take(harness.terminalReceipts); - const latest = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message") - ? [[event.turnItem.id, event.turnItem] as const] - : [], - ), - ); - const items = [...latest.values()]; - assert.deepEqual( - items.map((item) => item.type), - [...expected], - ); - assert.deepEqual( - items.map((item) => item.text), - [...texts], - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - + // regression: #15892 non-execution metadata is carried through, not guessed from result text it.effect.each(["cancelled", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", (kind) => @@ -4905,57 +4224,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("keeps usage warnings and provisioned overage silent", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-overage"), - text: "Continue.", - attachments: [], - }), - ); - for (const rate_limit_info of [ - { status: "allowed_warning" }, - { status: "rejected", overageStatus: "allowed" }, - { status: "rejected", overageStatus: "allowed_warning" }, - { status: "rejected", isUsingOverage: true }, - { status: "rejected", overageInUse: true }, - ]) { - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info, - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000000604", - }), - ); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000605", - result: "Done.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - assert.isFalse( - harness.events.some( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice", - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("keeps a later future-window event-supplied Claude reset over the embedded reset", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -5449,64 +4717,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - { recovered: false, expected: "Claude usage limit reached" }, - { recovered: true, expected: "Claude gave up after repeated API errors" }, - ])("tracks whether a rejected usage window recovered ($recovered)", ({ recovered, expected }) => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-claude-window-${recovered}`), - text: "Continue.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, - uuid: "00000000-0000-4000-8000-000000000608", - session_id: WAKE_NATIVE_SESSION, - }), - ); - if (recovered) { - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { status: "allowed", rateLimitType: "five_hour" }, - uuid: "00000000-0000-4000-8000-000000000609", - session_id: WAKE_NATIVE_SESSION, - }), - ); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000610", - result: recovered ? "API Error" : "API Error: 400 Specific provider detail.", - terminalReason: "api_error", - }), - ); - - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.include(terminal.failure.message, expected); - assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect.each([ { name: "fails a /compact the usage limit stopped", @@ -5783,54 +4993,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([429, 401, 503, 529])( - "classifies the current Claude API status %s after rate-limit evidence", - (apiErrorStatus) => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-status-${apiErrorStatus}`), - text: "Continue.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantErrorFrame({ - uuid: "00000000-0000-4000-8000-000000000650", - error: "rate_limit", - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000651", - result: "API Error", - terminalReason: "api_error", - isError: true, - apiErrorStatus, - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.equal( - terminal.failure.class, - apiErrorStatus === 429 ? "usage_limit" : "provider_error", - ); - if (apiErrorStatus !== 429) - assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("keeps the measured context when a usage limit ends the turn", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -5996,53 +5158,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("fills zero message_start input from a positive assistant snapshot", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-snapshot-input-usage"), - text: "Continue.", - attachments: [], - }), - ); - const snapshotUsage = { - input_tokens: 5, - output_tokens: 3, - cache_creation_input_tokens: 100, - cache_read_input_tokens: 2_000, - }; - const frames = [ - usageStreamFrame({ - type: "message_start", - message: { id: "msg_snapshot", usage: zeroUsage }, - }), - usageSnapshotFrame("msg_snapshot", "00000000-0000-4000-8000-000000000690", snapshotUsage), - usageSnapshotFrame("msg_snapshot", "00000000-0000-4000-8000-000000000691", zeroUsage), - usageStreamFrame({ - type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { input_tokens: null, output_tokens: 40 }, - }), - // Frozen snapshots of the same message must not revert the merged output. - usageSnapshotFrame("msg_snapshot", "00000000-0000-4000-8000-000000000692", snapshotUsage), - usageSnapshotFrame("msg_snapshot", "00000000-0000-4000-8000-000000000693", zeroUsage), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000694", result: "Done." }), - ]; - for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "completed"); - assert.deepEqual(reportedContextTokens(harness.events), [2_108, 2_145]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("merges cumulative stream usage into the message_start counts", () => Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ @@ -6185,70 +5300,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("surfaces a Claude safety model fallback without failing the turn", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("claude-safety-fallback-attempt"), - text: "Continue the audit", - attachments: [], - }), - ); - const notice = "Safeguards flagged this message. Switched to Opus 4.8."; - const uuid = "00000000-0000-4000-8000-000000000301"; - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "model_refusal_fallback", - trigger: "refusal", - direction: "retry", - original_model: "claude-fable-5", - fallback_model: "claude-opus-4-8", - request_id: "request-safety-fallback", - api_refusal_category: "cyber", - api_refusal_explanation: null, - content: notice, - session_id: WAKE_NATIVE_SESSION, - uuid, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000302", - result: "Audit complete.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - const notices = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "system_notice" - ? [event.turnItem] - : [], - ); - assert.lengthOf(notices, 1); - assert.equal(notices[0]?.message, notice); - assert.equal(notices[0]?.status, "completed"); - assert.equal(notices[0]?.nativeItemRef?.nativeId, uuid); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isFalse( - harness.events.some( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "error", - ), - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), - ), - ), - ); - it.effect("shows a hook-dropped Claude prompt once and skips info-level lines", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -6699,109 +5750,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("titles Claude reads, searches, and skills on tool completion", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-read-images"), - text: "Read the files", - attachments: [], - }), - ); - const tools = [ - { id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } }, - { id: "text", name: "Read", input: { file_path: "/workspace/README.md" } }, - { id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } }, - { id: "skill", name: "Skill", input: { skill: "full-send" } }, - { - id: "write", - name: "Write", - input: { file_path: "/workspace/output.png", content: "text" }, - }, - ]; - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - uuid: "00000000-0000-4000-8000-000000000601", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - id: "msg_image_reads", - model: "claude-sonnet-4-6", - type: "message", - role: "assistant", - content: tools.map((tool) => ({ type: "tool_use", ...tool })), - stop_reason: "tool_use", - stop_sequence: null, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "user", - uuid: "00000000-0000-4000-8000-000000000602", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - role: "user", - content: tools.map((tool) => ({ - type: "tool_result", - tool_use_id: tool.id, - content: "ok", - })), - }, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000603", result: "Read files" }), - ); - yield* Queue.take(harness.terminalReceipts); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.status === "completed" - ? [event.turnItem] - : [], - ); - const image = items.find((item) => item.nativeItemRef?.nativeId === "image"); - assert.equal(image?.type, "dynamic_tool"); - if (image?.type === "dynamic_tool") - assert.equal(image.viewedImagePath, "/workspace/reference.png"); - assert.equal(image?.title, "Read /workspace/reference.png"); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "text")?.title, - "Read /workspace/README.md", - ); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "search")?.title, - "Searched TODO in src", - ); - assert.equal( - items.find((item) => item.nativeItemRef?.nativeId === "skill")?.title, - "Skill: full-send", - ); - for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image")) - assert.notProperty(item, "viewedImagePath"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), - ), - ), - ); - it.effect("projects WebFetch as a question and answer and WebSearch as hits and summary", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -7612,33 +6560,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each(["success false", "error result", "unknown id"] as const)( - "does not emit a plan update for TaskUpdate with %s", - (reason) => - Effect.gen(function* () { - const harness = yield* makeTaskToolHarness; - yield* harness.startTurn(); - yield* harness.call(createTask("1", "Inspect")); - yield* harness.call( - updateTask( - { taskId: reason === "unknown id" ? "missing" : "1", status: "completed" }, - reason !== "success false", - ), - { isError: reason === "error result" }, - ); - yield* harness.finishTurn(); - assert.lengthOf(harness.plans(), 1); - assert.deepEqual(harness.plans()[0]?.steps, [ - { id: "task-1", text: "Inspect", status: "pending" }, - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect("replaces task state with TaskList, including an empty list", () => Effect.gen(function* () { const harness = yield* makeTaskToolHarness; @@ -7758,6 +6679,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #14964 structured task output attaches to exactly one tool result it.effect("does not associate structured task output with multiple tool results", () => Effect.gen(function* () { const harness = yield* makeTaskToolHarness; @@ -7811,7 +6733,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [event.turnItem] : [], ); - yield* awaitUntil(() => retryItems().length === 1, "Claude retry item"); + yield* harness.until(() => retryItems().length === 1); const runningRetry = retryItems()[0]; assert.equal(runningRetry?.status, "running"); assert.equal(runningRetry?.failure.code, "api_error_529"); @@ -7828,7 +6750,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { text: "Opening GitHub.", }), ); - yield* awaitUntil(() => retryItems().length === 2, "resolved Claude retry item"); + yield* harness.until(() => retryItems().length === 2); assert.lengthOf(harness.terminalEvents(), 0); const recoveredRetry = retryItems()[1]; assert.equal(recoveredRetry?.id, runningRetry?.id); @@ -7842,7 +6764,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Opened GitHub.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); + yield* harness.awaitTerminal(1); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7851,125 +6773,56 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("carries exhausted retry progress into the terminal provider error", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-api-retry-exhausted"), - text: "Open github.com.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 10, - max_retries: 10, - retry_delay_ms: 38_010, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000203", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000204", - result: "Claude is temporarily overloaded.", - isError: true, - apiErrorStatus: 529, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed Claude turn"); - - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.deepEqual(terminal.retry, { - attempt: 10, - maxAttempts: 10, - retryDelayMs: 38_010, - }); - assert.isDefined(terminal.retryStartedAt); - assert.equal(terminal.failure.code, "api_error_529"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - "api_error", - "malformed_tool_use_exhausted", - "budget_exhausted", - "structured_output_retry_exhausted", - "tool_deferred_unavailable", - "turn_setup_failed", - "blocking_limit", - "rapid_refill_breaker", - "prompt_too_long", - "image_error", - "model_error", - "overloaded_status", - ] as const)("fails a success-shaped Claude result with %s", (terminalReason) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), - text: "Complete the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000205", - result: "Provider failure details.", - isError: false, - ...(terminalReason === "overloaded_status" - ? { apiErrorStatus: 529 } - : { terminalReason }), - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.isNotEmpty(terminal.failure.message); - assert.equal(terminal.failure.class, "provider_error"); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === "Provider failure details.", + it.effect.each(["api_error", "overloaded_status"] as const)( + "fails a success-shaped Claude result with %s", + (terminalReason) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.equal(terminal.failure.class, "provider_error"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); - assert.equal( - terminal.failure.code, - terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), - ), ); // Frames follow #19832: a full context window ends the turn with @@ -8075,7 +6928,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); const afterStart = providerThreadRosterEvents(harness.events).filter( (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, @@ -8104,24 +6957,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => - event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1", - ), - "subagent shell roster", + yield* harness.until(() => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1", + ), ); yield* Queue.offer(harness.sdkMessages, emptyRoster); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "empty roster clear", + yield* harness.until(() => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), ); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( @@ -8253,7 +7102,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, makeResultFrame({ uuid: nextUuid(), result: "Watching." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread .pendingBackgroundTasks; @@ -8292,7 +7141,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -8304,57 +7153,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); assert.equal(closes, 1, "Stop must close the CLI process that owns the task"); - yield* awaitUntil( - () => - (providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks?.length ?? 0) === 0, - "roster clear after Stop", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("a settled Stop with no CLI process left succeeds and clears the roster", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions(); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-settled-stop-no-process"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; - assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); - - yield* Queue.shutdown(harness.sdkMessages); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "query exit"); - - yield* harness.runtime.interruptTurn({ - providerThread: settledThread ?? harness.providerThread, - providerTurnId: harness.terminalEvents()[0]!.providerTurnId, - requestRuntimeRestart: true, - }); - yield* awaitUntil( + yield* harness.until( () => (providerThreadRosterEvents(harness.events).at(-1)?.providerThread .pendingBackgroundTasks?.length ?? 0) === 0, - "roster clear after Stop", ); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); @@ -8369,6 +7171,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("a settled Stop leaves a turn that replaced the closing process alone", () => Effect.scoped( Effect.gen(function* () { + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -8392,7 +7195,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }), + }).pipe(Effect.andThen(changes.notify)), }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), @@ -8436,7 +7239,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -8459,7 +7262,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(processQueues[0]!, wakeTaskStarted); yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); + yield* changes.until(() => terminals().length === 1); const settledThread = providerThreadRosterEvents(events).at(-1)?.providerThread; const settledTurn = terminals()[0]; assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); @@ -8498,18 +7301,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000905", }), ); - yield* awaitUntil( + yield* changes.until( () => providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks?.[0] ?.taskId === replacementTaskId, - "replacement roster", ); yield* Queue.shutdown(processQueues[0]!); yield* Fiber.join(stop); assert.isTrue(yield* hasPendingBackgroundWork); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "late roster events"); + yield* yieldTicks(50); const latestThread = providerThreadRosterEvents(events).at(-1)?.providerThread; assert.equal(latestThread?.status, "active"); assert.deepEqual( @@ -8524,7 +7325,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Started another build.", }), ); - yield* awaitUntil(() => terminals().length === 2, "replacement turn terminal"); + yield* changes.until(() => terminals().length === 2); yield* Queue.offer( processQueues[1]!, claudeSdkFrame({ @@ -8540,69 +7341,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { text: WAKE_ASSISTANT_TEXT, }), ); - yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("clears the roster when a turn fails", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const failedResult = claudeSdkFrame({ - type: "result", - subtype: "error_during_execution", - duration_ms: 10, - duration_api_ms: 10, - is_error: true, - num_turns: 1, - result: "boom", - stop_reason: "end_turn", - total_cost_usd: 0, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - modelUsage: {}, - permission_denials: [], - errors: ["boom"], - terminal_reason: "model_error", - uuid: "00000000-0000-4000-8000-000000000203", - session_id: WAKE_NATIVE_SESSION, - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-roster-fail"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* awaitUntil( - () => - providerThreadRosterEvents(harness.events).some( - (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ), - "roster after task_started", - ); - yield* Queue.offer(harness.sdkMessages, failedResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - - const afterFailure = providerThreadRosterEvents(harness.events).at(-1); - assert.deepEqual(afterFailure?.providerThread.pendingBackgroundTasks ?? [], []); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + yield* changes.until(() => continuationRequests.length === 1); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -8616,6 +7355,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { () => Effect.scoped( Effect.gen(function* () { + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -8700,7 +7440,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -8753,15 +7493,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "A settled with background work.", }), ); - yield* awaitUntil( - () => - events.some( - (event) => - event.type === "turn.terminal" && - event.providerThreadId === providerThreadA.id && - event.status === "completed", - ), - "thread A terminal", + yield* changes.until(() => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadA.id && + event.status === "completed", + ), ); const rosterAAfterSettle = providerThreadRosterEvents(events).findLast( (event) => event.providerThread.id === providerThreadA.id, @@ -8784,15 +7522,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); assert.equal(processQueues.length, 2); - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.id === providerThreadA.id && - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "sibling A roster cleared idle on process replacement", + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadA.id && + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), ); assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); @@ -8811,14 +7547,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: nativeIds[1], }), ); - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.id === providerThreadB.id && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ), - "thread B roster populated", + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadB.id && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), ); assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); assert.isTrue(yield* hasPendingBackgroundWork); @@ -8850,15 +7584,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: nativeIds[1], }), ); - yield* awaitUntil( - () => - events.some( - (event) => - event.type === "turn.terminal" && - event.providerThreadId === providerThreadB.id && - event.status === "failed", - ), - "thread B failed terminal", + yield* changes.until(() => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadB.id && + event.status === "failed", + ), ); const rosterBAfterFail = providerThreadRosterEvents(events).findLast( @@ -8957,7 +7689,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { 0, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -8972,14 +7704,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, planToolUse); // The SDK asks for permission only after streaming the tool_use. - yield* awaitUntil( - () => Queue.sizeUnsafe(harness.sdkMessages) === 0, - "the tool_use frame to be consumed", - ); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "the tool_use frame to be handled"); + yield* harness.offerAndWait(planToolUse); const canUseTool = harness.getOpenedOptions()?.canUseTool; assert.isFunction(canUseTool); const callback = yield* Effect.promise(() => @@ -9000,10 +7726,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, planToolResult); yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); + yield* harness.awaitContinuation(1); yield* Queue.offer( harness.sdkMessages, @@ -9025,7 +7748,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { 1, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); + yield* harness.awaitTerminal(2); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -9041,7 +7764,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal"); + yield* harness.awaitTerminal(3); const latestItems = new Map( harness.events.flatMap((event) => @@ -9157,7 +7880,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const bash = harness.events.findLast( (event) => @@ -9337,7 +8060,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Done.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const toolItems = (nativeId: string) => harness.events.flatMap((event) => @@ -9486,7 +8209,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-0000000007b1", result: "Stopped." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* startTurn("attempt-claude-step-usage-next"); yield* harness.offerAndWait( @@ -9500,7 +8223,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-0000000007b4", result: "Done." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); + yield* harness.awaitTerminal(2); const toolItems = (nativeId: string) => harness.events.flatMap((event) => @@ -9599,7 +8322,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, makeResultFrame({ uuid: "00000000-0000-4000-8000-0000000007a2", result: "Done." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const fetched = harness.events.findLast( (event) => @@ -9664,7 +8387,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { 0, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -9678,8 +8401,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy: approvalPolicy, }), ); - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( claudeSdkFrame({ type: "assistant", message: { @@ -9701,12 +8423,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil( - () => Queue.sizeUnsafe(harness.sdkMessages) === 0, - "the tool_use frame to be consumed", - ); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "the tool_use frame to be held"); const permission = yield* Effect.promise(() => harness.getOpenedOptions()!.canUseTool!( "Bash", @@ -9719,9 +8435,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ).pipe(Effect.forkScoped); - yield* awaitUntil( - () => harness.events.some((event) => event.type === "runtime_request.updated"), - "the approval request", + yield* harness.until(() => + harness.events.some((event) => event.type === "runtime_request.updated"), ); const request = harness.events.findLast( (event) => event.type === "runtime_request.updated", @@ -9749,18 +8464,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("drops a zero-turn task-notification result while awaiting a prompt echo", () => + it.effect("releases output held for a prompt echo when the stream ends", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + }); const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2"); + const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); + const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); const stamp = (frame: SDKMessage, promptIndex: number) => claudeSdkFrame({ ...frame, user_message_uuid: harness.promptUuid(promptIndex), }); + const assistantTexts = () => + harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -9775,18 +8498,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer( harness.sdkMessages, stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }), + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), 0, ), ); yield* Queue.offer( harness.sdkMessages, stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), 0, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -9799,43 +8522,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, }), ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000764", text: "Stale." }), - ); - yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }), - 1, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }), - 1, - ), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 0); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ), - ["One.", "Stale.", "Two."], - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === STALE_TASK_NOTIFICATION_RESULT_TEXT, - ), + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), ); + assert.deepEqual(assistantTexts(), ["One."]); + + yield* Queue.shutdown(harness.sdkMessages); + yield* harness.awaitTerminal(2); + assert.deepEqual(assistantTexts(), ["One.", "Two."]); + assert.equal(harness.terminalEvents()[1]?.status, "failed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -9844,125 +8539,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("releases output held for a prompt echo when the stream ends", () => + it.effect("fails a prompt Claude completes without starting a turn for it", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => Queue.shutdown(sdkMessages), - }); + // Claude answers a prompt whose uuid its transcript already holds with + // a lone completed lifecycle frame and never runs a turn for it. + const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); - const stamp = (frame: SDKMessage, promptIndex: number) => - claudeSdkFrame({ - ...frame, - user_message_uuid: harness.promptUuid(promptIndex), - }); - const assistantTexts = () => - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ); - yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: firstAttempt, - text: "First.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), - 0, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), - 0, - ), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: secondAttempt, - text: "Second.", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), - ); - let heldYields = 0; - yield* awaitUntil(() => heldYields++ >= 50, "unechoed frame to be held"); - assert.deepEqual(assistantTexts(), ["One."]); - - yield* Queue.shutdown(harness.sdkMessages); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); - assert.deepEqual(assistantTexts(), ["One.", "Two."]); - assert.equal(harness.terminalEvents()[1]?.status, "failed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("gives the same run attempt a fresh prompt uuid on every offer", () => - Effect.scoped( - Effect.gen(function* () { - const original = yield* makeWakeHarness; - const copy = yield* makeWakeHarness; - const now = yield* DateTime.now; - for (const harness of [original, copy]) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-shared-copy"), - text: "Continue where you left off.", - attachments: [], - }), - ); - } - assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("fails a prompt Claude completes without starting a turn for it", () => - Effect.scoped( - Effect.gen(function* () { - // Claude answers a prompt whose uuid its transcript already holds with - // a lone completed lifecycle frame and never runs a turn for it. - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"), - text: "Bump this PR to the latest main.", + attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"), + text: "Bump this PR to the latest main.", attachments: [], }), ); @@ -9976,7 +8566,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const [terminal] = harness.terminalEvents(); assert.equal(terminal?.status, "failed"); if (terminal?.status !== "failed") return; @@ -9990,6 +8580,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #16897 a prompt whose uuid the session already holds must not hang or end early it.effect("keeps a started prompt running until its result", () => Effect.scoped( Effect.gen(function* () { @@ -10033,7 +8624,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { user_message_uuid: harness.promptUuid(0), }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( @@ -10061,27 +8652,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.isTrue(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); + yield* harness.offerAndWait(wakeNotification); assert.lengthOf(harness.continuationRequests, 0); assert.isFalse(yield* harness.hasPendingBackgroundWork); yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* harness.awaitContinuation(1); assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId); assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id); assert.equal(harness.continuationRequests[0]?.driver, ClaudeAdapterV2.CLAUDE_PROVIDER); assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* Queue.offer(harness.sdkMessages, wakeResult); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "wake result to settle into the buffer"); + yield* harness.offerAndWait(wakeResult); assert.lengthOf(harness.continuationRequests, 1); assert.lengthOf(harness.terminalEvents(), 1); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -10111,7 +8698,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* harness.offerAndWait(wakeNotification); assert.lengthOf(harness.continuationRequests, 0); @@ -10136,7 +8723,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait(wakeAssistant); yield* harness.offerAndWait(wakeResult); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); + yield* harness.awaitTerminal(2); assert.equal(harness.terminalEvents()[1]?.status, "completed"); assert.lengthOf(harness.continuationRequests, 1); assert.isTrue( @@ -10152,61 +8739,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("does not offer a continuation for notification-only opaque work", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-notification-only"), - text: "Start opaque background work.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - yield* Queue.offer(harness.sdkMessages, wakeNotification); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 100, "notification-only quiet window"); - assert.lengthOf(harness.continuationRequests, 0); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-notification-only-continuation"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil( - () => harness.terminalEvents().length === 2, - "notification-only continuation terminal", - ); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.offeredMessages, 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect.each([true, false])( "carries a rejected wake rate limit into the continuation failure (event=%s)", (standaloneEvent) => @@ -10337,10 +8869,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* Queue.offer(harness.sdkMessages, wakeNotification); yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* harness.awaitContinuation(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -10354,7 +8886,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); - yield* awaitUntil(() => harness.offeredMessages.length === 2, "user prompt offered"); + yield* harness.until(() => harness.offeredMessages.length === 2); assert.lengthOf(harness.terminalEvents(), 1); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -10365,7 +8897,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "The build passed; nothing else pending.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); + yield* harness.awaitTerminal(2); assert.equal(harness.terminalEvents()[1]?.status, "completed"); assert.isFalse( harness.events.some( @@ -10386,7 +8918,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal"); + yield* harness.awaitTerminal(3); assert.equal(harness.terminalEvents()[2]?.status, "completed"); assert.lengthOf(harness.offeredMessages, 2); assert.isTrue( @@ -10410,114 +8942,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("terminalizes an agent server wake from a positive task-notification result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const wakeText = "The background command completed."; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-agent-server-wake"), - text: "Background task completed.", - attachments: [], - messageCreatedBy: "agent", - messageCreationSource: "server", - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-00000000010b", - text: wakeText, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-00000000010c", - result: wakeText, - numTurns: 154, - origin: { kind: "task-notification" }, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "server wake terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-agent-server-next"), - text: "What finished?", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-00000000010d", - result: "The background command finished.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "queued turn terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("terminalizes a user mobile turn from a positive task-notification result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const fallbackText = "The ordinary mobile turn completed."; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-mobile-notif-origin"), - text: "Complete this task.", - attachments: [], - messageCreationSource: "mobile", - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-00000000010e", - result: fallbackText, - numTurns: 60, - origin: { kind: "task-notification" }, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "mobile turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isTrue( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === fallbackText, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("ignores a zero-turn task-notification origin result during a normal user turn", () => Effect.scoped( Effect.gen(function* () { @@ -10541,7 +8965,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - yield* awaitUntil(() => harness.offeredMessages.length === 1, "recovery prompt offered"); + yield* harness.until(() => harness.offeredMessages.length === 1); // Live interleaving seen after interrupt recovery: a stale stopped // task_notification and its task-notification-origin result arrive @@ -10569,10 +8993,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); - yield* awaitUntil( - () => hasMessageText(probeAssistantText), - "probe assistant after stale task-notification result", - ); + yield* harness.until(() => hasMessageText(probeAssistantText)); assert.lengthOf(harness.terminalEvents(), 0); assert.isFalse(hasMessageText(staleResultText)); @@ -10591,7 +9012,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "user turn terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.isTrue(hasMessageText(recoveryAssistantText)); assert.isFalse(hasMessageText(staleResultText)); @@ -10603,60 +9024,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("terminalizes a zero-turn task-notification result in a provider continuation", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-zero-continuation-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-00000000010f", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - }), - ); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-zero-continuation-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect( "emits one interrupted terminal for a positive task-notification result racing interrupt", () => @@ -10703,12 +9070,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { origin: { kind: "task-notification" }, }), ); - const terminalized = Exit.isSuccess( - yield* awaitUntil( - () => harness.terminalEvents().length === 1, - "interrupted terminal", - ).pipe(Effect.exit), - ); + const terminalized = Exit.isSuccess(yield* harness.awaitTerminal(1).pipe(Effect.exit)); yield* Deferred.succeed(closeGate, undefined); assert.isTrue(terminalized); assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); @@ -10721,109 +9083,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("drops zero-turn task-notification debris racing interrupt", () => - Effect.scoped( - Effect.gen(function* () { - const closeGate = yield* Deferred.make(); - const testScope = yield* Scope.Scope; - yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined)); - const interruptStarted = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => - Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), - stopTask: () => Effect.void, - interrupt: Deferred.succeed(interruptStarted, undefined), - }); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-claude-interrupt-zero-notif"); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }); - const staleText = "Zero-turn debris must not leak."; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId, - text: "Stop this task.", - attachments: [], - }), - ); - yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* Deferred.await(interruptStarted); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000116", - result: staleText, - numTurns: 0, - origin: { kind: "task-notification" }, - }), - ); - let debrisYields = 0; - yield* awaitUntil(() => debrisYields++ >= 50, "zero-turn debris consumed"); - yield* Deferred.succeed(closeGate, undefined); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.isFalse( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === staleText, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("fails a positive task-notification error result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-positive-notif-error"), - text: "Run the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000117", - result: "The task failed.", - numTurns: 7, - origin: { kind: "task-notification" }, - subtype: "error_during_execution", - isError: true, - errors: ["The task failed."], - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("settles a continuation turn immediately when no wake output is buffered", () => + it.effect("settles a continuation turn immediately when no wake output is buffered", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -10842,7 +9102,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "spurious terminal"); + yield* harness.awaitTerminal(1); assert.equal(harness.terminalEvents()[0]?.status, "completed"); assert.lengthOf(harness.offeredMessages, 0); }).pipe( @@ -11005,7 +9265,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000952", result: "Started." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); // Both subagents end together. Claude runs an empty turn for the first // notification and opens the real one a millisecond later, all before @@ -11066,445 +9326,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], providerTurnOrdinal: 2, messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - // Claude is still thinking, so the run stays working rather than - // settling with nothing to show. - assert.lengthOf(harness.terminalEvents(), 1); - - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 1); - assert.isTrue( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.runId === RunId.make(`run-${wakeAttempt}`) && - event.message.text === WAKE_RESULT_TEXT, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("a subagent re-run in the foreground does not join a later wake", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const taskId = "task-foreground-rerun"; - const toolUseId = "toolu_foreground_rerun"; - const userTurn = (attempt: string, providerTurnOrdinal: number) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(attempt), - text: "Go on.", - attachments: [], - providerTurnOrdinal, - }), - ); - const result = (uuid: string) => makeResultFrame({ uuid, result: "Done." }); - const ended = (uuid: string) => - makeSubagentNotificationFrame({ taskId, toolUseId, summary: "AUDITED", uuid }); - - yield* userTurn("attempt-rerun-1", 1); - yield* Queue.offer( - harness.sdkMessages, - makeSubagentTaskStartedFrame({ - taskId, - toolUseId, - uuid: "00000000-0000-4000-8000-000000000901", - }), - ); - yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000902")); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - yield* Queue.offer(harness.sdkMessages, ended("00000000-0000-4000-8000-000000000903")); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); - yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000904")); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-rerun-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake terminal"); - - yield* userTurn("attempt-rerun-3", 3); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...makeSubagentTaskStartedFrame({ - taskId, - toolUseId, - uuid: "00000000-0000-4000-8000-000000000905", - }), - is_backgrounded: false, - }), - ); - yield* Queue.offer(harness.sdkMessages, ended("00000000-0000-4000-8000-000000000906")); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000907")); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "third turn terminal"); - - yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* awaitUntil(() => harness.continuationRequests.length === 2, "command wake"); - assert.equal( - harness.continuationRequests[1]?.notification?.summary, - `Command "${WAKE_TASK_DESCRIPTION}" finished`, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("a turn that fails to start does not expire a queued wake's report", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const userTurn = ( - attempt: string, - providerTurnOrdinal: number, - attachments: ProviderAdapter.ProviderAdapterV2TurnInput["message"]["attachments"] = [], - ) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(attempt), - text: "Go on.", - attachments, - providerTurnOrdinal, - }), - ); - - yield* userTurn("attempt-failed-start-1", 1); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait(wakeNotification); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - const missing = ChatImageAttachment.make({ - type: "image", - id: ChatAttachmentId.make("thread-claude-wake-12345678-1234-1234-1234-123456789abc"), - name: "gone.png", - mimeType: "image/png", - sizeBytes: 4, - }); - const failed = yield* userTurn("attempt-failed-start-2", 2, [missing]).pipe(Effect.flip); - assert.equal( - makeProviderFailure({ cause: failed }).message, - "The provider could not start this turn: an attachment could not be read. Retry the turn; if it keeps failing, check the provider setup and server logs.", - ); - - yield* userTurn("attempt-failed-start-3", 2); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000911", result: "Answered." }), - ); - yield* Queue.take(harness.terminalReceipts); - - yield* harness.offerAndWait(wakeAssistant); - const continuation = yield* Queue.take(harness.continuationReceipts); - assert.equal( - continuation.notification?.summary, - `Command "${WAKE_TASK_DESCRIPTION}" finished`, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps a subagent a queued wake turn launches with its continuation", () => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "a-wake-launched-subagent"; - const TOOL_USE_ID = "toolu_01WakeLaunchedAgent"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-wake-subagent-1"); - const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2"); - const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3"); - const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); - const stamp = (frame: SDKMessage, promptIndex: number) => - claudeSdkFrame({ - ...frame, - user_message_uuid: harness.promptUuid(promptIndex), - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: firstAttempt, - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }), - 0, - ), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: userAttempt, - text: "Reply with exactly: USER_REPLY", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - const wakeFrames = [ - wakeNotification, - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_wake_agent", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: TOOL_USE_ID, - name: "Agent", - input: { description: "Audit recent commits", prompt: "Audit them." }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000771", - session_id: WAKE_NATIVE_SESSION, - }), - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000772", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000773", - text: "AUDIT_DONE", - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: "AUDIT_DONE", - uuid: "00000000-0000-4000-8000-000000000774", - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: TOOL_USE_ID, content: "AUDIT_DONE" }], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000775", - session_id: WAKE_NATIVE_SESSION, - }), - wakeResult, - stamp( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000776", - text: "USER_REPLY", - }), - 1, - ), - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }), - 1, - ), - ]; - for (const frame of wakeFrames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: continuationAttempt, - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal"); - - const subagentRuns = harness.events.flatMap((event) => - event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === TASK_ID - ? [event.subagent.runId] - : [], - ); - assert.isNotEmpty(subagentRuns); - assert.isTrue(subagentRuns.every((runId) => runId === runOf(continuationAttempt))); - const finalSubagent = harness.events.findLast( - (event) => - event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === TASK_ID, - ); - assert.equal( - finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, - "completed", - ); - const userRunItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.runId === runOf(userAttempt) - ? [event.turnItem.type] - : [], - ); - assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("routes a subagent that starts while the root turn is idle", () => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "task-idle-subagent"; - const TOOL_USE_ID = "toolu-idle-subagent"; - const FINAL_REPORT = "Idle auditor done."; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-idle-subagent-1"), - text: "Wait for background work.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000331", - result: "Waiting in the background.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - - const idleFrames = [ - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000332", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000333", - text: "Idle auditor working.", - bashToolUseId: "toolu-idle-bash", - }), - makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000334", - toolUseId: "toolu-idle-bash", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000335", - text: FINAL_REPORT, - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: FINAL_REPORT, - uuid: "00000000-0000-4000-8000-000000000336", - }), - ]; - for (const frame of idleFrames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000337", - result: "The idle auditor finished.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-idle-subagent-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - - const routing = subagentRouting(harness.events, ["toolu-idle-bash"]); - assert.isDefined(routing.childThreadId); - assert.deepEqual( - [...(routing.toolThreadIds.get("toolu-idle-bash") ?? [])], - [routing.childThreadId], - ); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ - "Idle auditor working.", - FINAL_REPORT, - ]); - assert.deepEqual(routing.assistantTexts(harness.threadId), [ - "Waiting in the background.", - "The idle auditor finished.", - ]); - const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); - assert.equal( - finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, - "completed", - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + messageCreationSource: "provider", + }), + ); + // Claude is still thinking, so the run stays working rather than + // settling with nothing to show. + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.runId === RunId.make(`run-${wakeAttempt}`) && + event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ), - ), ); it.effect("streams a background subagent's work while the root turn is idle", () => @@ -11550,7 +9397,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + yield* harness.awaitTerminal(1); for (const frame of [ makeSubagentToolResultFrame({ @@ -11575,18 +9422,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => { + yield* harness.until(() => { const event = latestToolItem(IDLE_BASH); return event?.type === "turn_item.updated" && event.turnItem.status === "running"; - }, "idle Bash call"); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.progress === "Reading the last commits", - ), - "idle progress", + }); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), ); const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); @@ -11615,7 +9460,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* harness.awaitContinuation(1); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -11636,7 +9481,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ @@ -11668,210 +9513,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - const startSubagentWakeWithoutWakeTurn = ( - options?: Parameters[0], - bufferedFrames: ReadonlyArray = [], - ) => - Effect.gen(function* () { - const TASK_ID = "task-wake-never-opened"; - const TOOL_USE_ID = "toolu-wake-never-opened"; - const harness = yield* makeWakeHarnessWithOptions(options); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-never-opened-1"), - text: "Audit in the background.", - attachments: [], - }), - ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000981", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000982", - result: "Auditing in the background.", - }), - ]) { - yield* harness.offerAndWait(frame); - } - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - yield* harness.offerAndWait( - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: "Audit done.", - uuid: "00000000-0000-4000-8000-000000000983", - }), - ); - yield* Queue.take(harness.continuationReceipts); - for (const frame of bufferedFrames) { - yield* harness.offerAndWait(frame); - } - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-never-opened-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - assert.lengthOf(harness.terminalEvents(), 1); - return harness; - }); - - it.effect("settles a subagent wake run although a subagent reports progress meanwhile", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* startSubagentWakeWithoutWakeTurn(); - - yield* TestClock.adjust("1 minute"); - assert.lengthOf(harness.terminalEvents(), 1); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: "task-still-running-elsewhere", - description: "Reading more files", - uuid: "00000000-0000-4000-8000-000000000984", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* TestClock.adjust("1 minute"); - assert.lengthOf(harness.terminalEvents(), 2); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - assert.lengthOf(harness.offeredMessages, 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("settles a subagent wake run with only subagent messages buffered", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* startSubagentWakeWithoutWakeTurn(undefined, [ - ...makeSubagentAssistantFrames({ - parentToolUseId: "toolu-wake-never-opened", - text: "Audit done.", - uuid: "00000000-0000-4000-8000-000000000985", - }), - makeSubagentToolResultFrame({ - parentToolUseId: "toolu-wake-never-opened", - toolUseId: "toolu-wake-child-bash", - uuid: "00000000-0000-4000-8000-000000000986", - }), - ]); - - yield* TestClock.adjust("2 minutes"); - assert.lengthOf(harness.terminalEvents(), 2); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps a subagent wake run open while Claude works on the wake turn", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* startSubagentWakeWithoutWakeTurn(); - - yield* harness.offerAndWait(wakeTurnInit); - yield* TestClock.adjust("2 minutes"); - assert.lengthOf(harness.terminalEvents(), 1); - - yield* harness.offerAndWait(wakeResult); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps a subagent wake run open when root output starts streaming", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* startSubagentWakeWithoutWakeTurn(); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "stream_event", - event: { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "Working" }, - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000987", - session_id: WAKE_NATIVE_SESSION, - }), - ); - - yield* TestClock.adjust("2 minutes"); - assert.lengthOf(harness.terminalEvents(), 1); - yield* harness.offerAndWait(wakeResult); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("does not complete a subagent wake run while Stop is pending", () => - Effect.scoped( - Effect.gen(function* () { - const stopRequested = yield* Deferred.make(); - const releaseStop = yield* Deferred.make(); - const harness = yield* startSubagentWakeWithoutWakeTurn({ - interrupt: Deferred.succeed(stopRequested, undefined).pipe( - Effect.andThen(Deferred.await(releaseStop)), - ), - close: Queue.shutdown, - }); - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: "turn:attempt-claude-wake-never-opened-2", - }), - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(stopRequested); - - yield* TestClock.adjust("2 minutes"); - assert.lengthOf(harness.terminalEvents(), 1); - - yield* Deferred.succeed(releaseStop, undefined); - yield* Fiber.join(stop); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 2); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { @@ -11904,7 +9545,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + yield* harness.awaitTerminal(1); for (const frame of [ claudeSdkFrame({ @@ -11958,12 +9599,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil( - () => - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", - ), - "outer work while the nested start waits", + yield* harness.until(() => + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", + ), ); assert.lengthOf(harness.continuationRequests, 0); for (const frame of [ @@ -11997,15 +9636,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.progress === "Waiting on the nested check", - ), - "outer progress", + yield* harness.awaitContinuation(1); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Waiting on the nested check", + ), ); yield* Queue.offer( harness.sdkMessages, @@ -12027,7 +9664,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); assert.isFalse( harness.events.some( @@ -12129,13 +9766,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* userTurn("attempt-claude-earlier-nested-2", 2); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); + yield* harness.awaitTerminal(2); for (const frame of [ makeSubagentNotificationFrame({ @@ -12157,13 +9794,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", - ), - "outer work after the nested call returns", + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", + ), ); assert.isFalse( @@ -12308,88 +9943,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000a04", toolUseId: BASH, }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a05", - result: "Audited.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - const bashItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH - ? [`${event.turnItem.type}:${event.turnItem.status}`] - : [], - ); - assert.deepEqual(bashItems, [ - "command_execution:running", - "command_execution:failed", - "command_execution:completed", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("ends a subagent's open call when Stop's close of the CLI times out", () => - Effect.scoped( - Effect.gen(function* () { - const TOOL_USE_ID = "toolu-idle-close-timeout"; - const BASH = "toolu-idle-close-timeout-bash"; - const harness = yield* makeWakeHarnessWithOptions(); - const now = yield* DateTime.now; - const bashStatuses = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH - ? [`${event.turnItem.type}:${event.turnItem.status}`] - : [], - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-idle-close-timeout"), - text: "Audit in the background.", - attachments: [], - }), - ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: "task-idle-close-timeout", - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000985", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000986", - bashToolUseId: BASH, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000987", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - assert.deepEqual(bashStatuses(), ["command_execution:running"]); - - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: harness.terminalEvents()[0]!.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(stop); - assert.deepEqual(bashStatuses(), [ + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a05", + result: "Audited.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + const bashItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(bashItems, [ "command_execution:running", - "command_execution:interrupted", + "command_execution:failed", + "command_execution:completed", ]); }).pipe( Effect.provide( @@ -12442,7 +10012,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + yield* harness.awaitTerminal(1); for (const frame of makeSubagentAssistantFrames({ parentToolUseId: OUTER_TOOL_USE_ID, @@ -12451,7 +10021,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { })) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => bashItems().length === 1, "idle Bash call"); + yield* harness.until(() => bashItems().length === 1); const bashResultFrame = makeSubagentToolResultFrame({ parentToolUseId: OUTER_TOOL_USE_ID, @@ -12520,14 +10090,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); + yield* harness.awaitContinuation(1); yield* Queue.shutdown(harness.sdkMessages); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* yieldTicks(50); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -12549,7 +10115,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "The audit finished.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); const input = JSON.stringify("git log -5"); assert.deepEqual(bashItems(), [ @@ -12619,7 +10185,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + yield* harness.awaitTerminal(1); yield* Queue.offer( harness.sdkMessages, @@ -12637,7 +10203,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call"); + yield* harness.until(() => sendItems().length >= 1); for (const frame of [ claudeSdkFrame({ @@ -12671,15 +10237,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); + yield* harness.awaitContinuation(1); const beforeExit = sendItems(); yield* Queue.shutdown(harness.sdkMessages); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* yieldTicks(50); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -12701,7 +10263,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "The audit finished.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); const input = JSON.stringify(SEND_INPUT); assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); @@ -12717,82 +10279,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("releases held frames before the notification that first names the tool use", () => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "task-late-tool-use"; - const TOOL_USE_ID = "toolu-late-tool-use"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), - text: "Run an auditor.", - attachments: [], - }), - ); - const frames = [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: TASK_ID, - description: "Audit recent commits", - task_type: "local_agent", - prompt: "Audit the last five commits.", - uuid: "00000000-0000-4000-8000-000000000341", - session_id: WAKE_NATIVE_SESSION, - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000342", - text: "Working.", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000344", - messageId: "msg_late_final", - text: "Part one.", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000345", - messageId: "msg_late_final", - text: "Part two.", - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: "Part one.\n\nPart two.", - uuid: "00000000-0000-4000-8000-000000000346", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000347", - result: "The auditor finished.", - }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - - const routing = subagentRouting(harness.events, []); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ - "Working.", - "Part one.", - "Part two.", - ]); - assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect.each([ ["", "none"], [" behind another subagent's start without one", "before"], @@ -12843,7 +10329,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + yield* harness.awaitTerminal(1); const otherStartFrame = claudeSdkFrame({ type: "system", @@ -12897,10 +10383,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); + yield* harness.awaitContinuation(1); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -12921,7 +10404,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); const routing = subagentRouting(harness.events, [BASH]); const childTexts = routing.assistantTexts(routing.childThreadId); @@ -12963,71 +10446,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => - Effect.scoped( - Effect.gen(function* () { - const TOOL_USE_ID = "toolu-retry-subagent"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-retry-subagent"), - text: "Run an auditor.", - attachments: [], - }), - ); - const frames = [ - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000351", - text: "Held before registration.", - }), - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 2, - max_retries: 10, - retry_delay_ms: 1_500, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000352", - session_id: WAKE_NATIVE_SESSION, - }), - makeSubagentTaskStartedFrame({ - taskId: "task-retry-subagent", - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000353", - }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - const childTexts = () => { - const routing = subagentRouting(harness.events, []); - return routing.childThreadId === undefined - ? [] - : routing.assistantTexts(routing.childThreadId); - }; - yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); - const retryStatuses = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem.status] - : [], - ); - assert.deepEqual(retryStatuses, ["running"]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("holds subagent frames that precede task_started and shows its result once", () => Effect.scoped( Effect.gen(function* () { @@ -13097,7 +10515,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { for (const frame of frames) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const routing = subagentRouting(harness.events, ["toolu-early-bash"]); assert.isDefined(routing.childThreadId); @@ -13279,7 +10697,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { for (const frame of frames) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const updates = harness.events.flatMap((event) => event.type === "subagent.updated" ? [event.subagent] : [], @@ -13437,7 +10855,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { for (const frame of frames) { yield* Queue.offer(harness.sdkMessages, frame); } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); const updates = harness.events.flatMap((event) => event.type === "subagent.updated" ? [event.subagent] : [], @@ -13619,98 +11037,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }; }); - it.effect.each([ - [false, "no notification"], - [true, "no notification"], - [false, "stop fails"], - [false, "stop hangs"], - ] as const)("never infers an outcome from Stop, owner settled %s and %s", ([idle, failure]) => - Effect.scoped( - Effect.gen(function* () { - const requested = yield* Deferred.make(); - const fixture = yield* makeStopFixture({ - stopTask: () => - Deferred.succeed(requested, undefined).pipe( - Effect.andThen( - failure === "stop fails" - ? new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "stopTask", - cause: "Task is no longer live", - }) - : failure === "stop hangs" - ? Effect.never - : Effect.void, - ), - ), - }); - if (idle) yield* fixture.finishParent(); - const stopping = yield* fixture.stop().pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.await(requested); - yield* TestClock.adjust("2 seconds"); - const result = yield* Fiber.join(stopping); - assert.isTrue(Exit.isFailure(result)); - assert.equal( - fixture.events.findLast((event) => event.type === "subagent.updated")?.subagent.status, - "running", - ); - assert.isTrue(yield* fixture.hasPendingBackgroundWork); - yield* fixture.notify("completed"); - assert.equal((yield* Queue.take(fixture.subagentReceipts)).subagent.status, "completed"); - assert.isFalse(yield* fixture.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([1, 2, 3, 4, 5, 6, 7, 8])( - "accepts completion during Stop at scheduler step %s", - (pauseAt) => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeStopFixture(); - const resumptions = yield* Queue.unbounded<() => void>(); - const scheduler = yield* Scheduler.Scheduler; - const dispatcher = scheduler.makeDispatcher(); - let checks = 0; - const stopping = yield* fixture.stop().pipe( - Effect.provideService(Scheduler.Scheduler, { - executionMode: scheduler.executionMode, - shouldYield: () => ++checks === pauseAt, - makeDispatcher: () => ({ - scheduleTask: (task, priority) => { - if (checks === pauseAt) Queue.offerUnsafe(resumptions, task); - else dispatcher.scheduleTask(task, priority); - }, - flush: () => dispatcher.flush(), - }), - }), - Effect.exit, - Effect.forkScoped, - ); - const resume = yield* Queue.take(resumptions); - yield* fixture.notify("completed"); - const completed = yield* Queue.take(fixture.subagentReceipts); - yield* Effect.sync(resume); - yield* TestClock.adjust("2 seconds"); - assert.deepEqual( - yield* Fiber.join(stopping), - Exit.succeed({ - status: "completed" as const, - completedAt: completed.subagent.completedAt!, - result: "Native outcome.", - }), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("retains native confirmation after the synchronous Agent tool result", () => Effect.scoped( Effect.gen(function* () { @@ -14015,155 +11341,45 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer( harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-subagent-model", - tool_use_id: toolUseId, - description: "Haiku puzzle", - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000207", - session_id: WAKE_NATIVE_SESSION, - }), - ); - if (source === "observed-after" || source === "synthetic-after") { - yield* Queue.offer(harness.sdkMessages, observed); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - result: "Spawned the subagent.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - const subagents = harness.events.filter((event) => event.type === "subagent.updated"); - const observedSlug = "claude-haiku-4-5"; - const initialModel = - source === "observed-before" - ? observedSlug - : source === "inherit" - ? parentModel - : source === "unknown" - ? null - : "claude-haiku-5-5"; - assert.equal(subagents[0]?.subagent.model, initialModel); - assert.equal( - subagents.at(-1)?.subagent.model, - source.startsWith("observed") ? observedSlug : initialModel, - ); - const child = harness.events.find((event) => event.type === "app_thread.created"); - assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, - { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, - { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, - ])( - "records the effort a $agentModel subagent runs at (Agent effort $requested)", - ({ agentModel, slug, requested, expected }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const toolUseId = "toolu-subagent-effort"; - const parentSelection = { - instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, - model: "claude-opus-4-6", - options: [ - { id: "effort", value: "max" }, - { id: "fastMode", value: false }, - ], - } satisfies ModelSelection; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-subagent-effort"), - text: "Spawn a subagent.", - attachments: [], - modelSelection: parentSelection, - }), - ); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: null, - message: { - model: parentSelection.model, - id: "msg_subagent_effort_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Agent", - input: { - description: "Research", - subagent_type: "general-purpose", - model: agentModel, - ...(requested === undefined ? {} : { effort: requested }), - prompt: "Research the topic.", - }, - }, - ], - }, - uuid: "00000000-0000-4000-8000-000000000211", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-subagent-effort", - tool_use_id: toolUseId, - description: "Research", - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000212", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: toolUseId, - message: { - model: slug, - id: "msg_subagent_effort_observed", - type: "message", - role: "assistant", - content: [{ type: "text", text: "Researching." }], - }, - uuid: "00000000-0000-4000-8000-000000000213", + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-subagent-model", + tool_use_id: toolUseId, + description: "Haiku puzzle", + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000207", session_id: WAKE_NATIVE_SESSION, }), + ); + if (source === "observed-after" || source === "synthetic-after") { + yield* Queue.offer(harness.sdkMessages, observed); + } + yield* Queue.offer( + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000214", + uuid: "00000000-0000-4000-8000-000000000208", result: "Spawned the subagent.", }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } + ); yield* Queue.take(harness.terminalReceipts); - - const subagentSelection = { - instanceId: parentSelection.instanceId, - model: slug, - ...(expected === null ? {} : { options: [{ id: "effort", value: expected }] }), - }; - const child = harness.events.find((event) => event.type === "app_thread.created"); - assert.deepEqual(child?.appThread.modelSelection, subagentSelection); const subagents = harness.events.filter((event) => event.type === "subagent.updated"); - assert.deepEqual( - subagents.map((event) => event.subagent.modelSelection), - subagents.map(() => subagentSelection), + const observedSlug = "claude-haiku-4-5"; + const initialModel = + source === "observed-before" + ? observedSlug + : source === "inherit" + ? parentModel + : source === "unknown" + ? null + : "claude-haiku-5-5"; + assert.equal(subagents[0]?.subagent.model, initialModel); + assert.equal( + subagents.at(-1)?.subagent.model, + source.startsWith("observed") ? observedSlug : initialModel, ); + const child = harness.events.find((event) => event.type === "app_thread.created"); + assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -14435,7 +11651,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil(() => subagentEvents().length === 1, "subagent node created"); + yield* harness.until(() => subagentEvents().length === 1); yield* Queue.offer( harness.sdkMessages, @@ -14459,10 +11675,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil( - () => subagentEvents().at(-1)?.subagent.status === "completed", - "subagent terminal", - ); + yield* harness.until(() => subagentEvents().at(-1)?.subagent.status === "completed"); assert.equal(subagentEvents().at(-1)?.subagent.result, "First line.\nSecond line."); @@ -14473,7 +11686,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Delegation completed.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + yield* harness.awaitTerminal(1); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -14533,7 +11746,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* harness.until(() => subagentEvents().length >= 1); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -14541,11 +11754,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Spawned the subagent in the background.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); assert.isTrue(yield* harness.hasPendingBackgroundWork); yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* harness.awaitContinuation(1); yield* Queue.offer( harness.sdkMessages, @@ -14567,7 +11780,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); assert.isFalse(yield* harness.hasPendingBackgroundWork); @@ -14656,7 +11869,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); + yield* harness.until(() => subagentEvents().length >= 1); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -14664,13 +11877,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Spawned the subagent in the background.", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* harness.awaitTerminal(1); yield* Queue.offer(harness.sdkMessages, firstNotification); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "first continuation request", - ); + yield* harness.awaitContinuation(1); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -14691,7 +11901,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + yield* harness.awaitTerminal(2); assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); assert.isFalse(yield* harness.hasPendingBackgroundWork); @@ -14774,10 +11984,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, frame); } yield* Queue.offer(harness.sdkMessages, secondNotification); - yield* awaitUntil( - () => harness.continuationRequests.length === 2, - "second continuation request", - ); + yield* harness.awaitContinuation(2); assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -14801,10 +12008,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil( - () => harness.terminalEvents().length === 3, - "resume continuation terminal", - ); + yield* harness.awaitTerminal(3); const statuses = subagentEvents().map((event) => event.subagent.status); const firstCompleted = statuses.indexOf("completed"); @@ -14851,6 +12055,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => Effect.scoped( Effect.gen(function* () { + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -14873,7 +12078,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }), + }).pipe(Effect.andThen(changes.notify)), }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), @@ -14914,7 +12119,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -14948,10 +12153,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(firstProcess, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); assert.isFalse(yield* hasPendingBackgroundWork); const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; @@ -14970,299 +12172,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, modelSelection: alternateModel, }), - ); - assert.equal(processQueues.length, 2); - - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - event.providerThread.updatedAt !== undefined, - ), - "roster cleared on process replace while remaining active", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ); - assert.isAtLeast(emptyActiveRosterEvents.length, 1); - assert.deepEqual( - emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], - [], - ); - assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - - const secondProcess = processQueues[1]!; - yield* Queue.offer(secondProcess, wakeNotification); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "stale notification settle"); - assert.lengthOf(continuationRequests, 0); - - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000602", - result: "Process restart turn finished.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "second turn terminal", - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect( - "preserves buffered local_bash notification classification across model/policy query replacement", - () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-buffer-replace-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = - []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-buffer-replace"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer(firstProcess, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); - assert.isTrue(yield* hasPendingBackgroundWork); - - yield* Queue.offer(firstProcess, wakeNotification); - let quietYields = 0; - yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); - assert.lengthOf(continuationRequests, 0); - - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), - text: "Switch model while background work completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000701", - result: "User turn finished after model switch.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", - ); - assert.isFalse(yield* hasPendingBackgroundWork); - assert.lengthOf(continuationRequests, 0); - - yield* Queue.offer(secondProcess, wakeResult); - yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); - assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - modelSelection: alternateModel, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 3, - "continuation terminal after buffered drain", - ); - assert.isTrue( - events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "subagent.updated" || - (event.type === "node.updated" && event.node.kind === "subagent"), - ), - ); - assert.isFalse( - events.some( - (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), - ), - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + assert.equal(processQueues.length, 2); + + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && + event.providerThread.updatedAt !== undefined, ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); + const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ); + assert.isAtLeast(emptyActiveRosterEvents.length, 1); + assert.deepEqual( + emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + [], + ); + assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); + + const secondProcess = processQueues[1]!; + yield* Queue.offer(secondProcess, wakeNotification); + yield* yieldTicks(50); + assert.lengthOf(continuationRequests, 0); + + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000602", + result: "Process restart turn finished.", + }), + ); + yield* changes.until( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ), ); it.effect( - "does not opaque-misclassify a buffered subagent notification across model/policy query replacement", + "preserves buffered local_bash notification classification across model/policy query replacement", () => Effect.scoped( Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-buffer-replace-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-buffer-replace-subagent"; - const SUBAGENT_SUMMARY = "SUB_BUFFER_REPLACE_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Background research", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", - uuid: "00000000-0000-4000-8000-000000000801", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-buffer-replace-subagent.output", - summary: SUBAGENT_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000802", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentAsyncAck = claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content: [{ type: "text", text: "Async agent launched successfully." }], - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000803", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { - isAsync: true, - status: "async_launched", - agentId: SUBAGENT_TASK_ID, - prompt: "Research then return SUB_BUFFER_REPLACE_DONE.", - }, - }); - + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-subagent-buffer-replace-", + prefix: "t3-claude-v2-buffer-replace-", }); const processQueues: Array> = []; const events: Array = []; @@ -15281,7 +12247,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }), + }).pipe(Effect.andThen(changes.notify)), }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), @@ -15306,12 +12272,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { assertComplete: Effect.void, }, }); - const threadId = ThreadId.make("thread-claude-subagent-buffer-replace"); + const threadId = ThreadId.make("thread-claude-buffer-replace"); const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-subagent-buffer-replace", - ), + providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); @@ -15324,7 +12288,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -15334,15 +12298,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const subagentEvents = () => - events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "subagent.updated" } - > => event.type === "subagent.updated", - ); const now = yield* DateTime.now; yield* runtime.startTurn( @@ -15350,33 +12305,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-a"), - text: "Spawn a background subagent and stop.", + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), + text: "Run the build in the background.", attachments: [], }), ); assert.equal(processQueues.length, 1); const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, subagentTaskStarted); - yield* awaitUntil(() => subagentEvents().length >= 1, "subagent node created"); - assert.equal(subagentEvents()[0]?.subagent.status, "running"); - yield* Queue.offer(firstProcess, subagentAsyncAck); - yield* Queue.offer( - firstProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000804", - result: "Spawned the subagent in the background.", - }), - ); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer(firstProcess, turnOneResult); + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); assert.isTrue(yield* hasPendingBackgroundWork); - yield* Queue.offer(firstProcess, subagentNotification); - yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); - assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); + yield* Queue.offer(firstProcess, wakeNotification); + yield* yieldTicks(50); + assert.lengthOf(continuationRequests, 0); const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; yield* runtime.startTurn( @@ -15384,8 +12327,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-user"), - text: "Switch model while the subagent completes.", + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), + text: "Switch model while background work completes.", attachments: [], providerTurnOrdinal: 2, modelSelection: alternateModel, @@ -15396,31 +12339,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer( secondProcess, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000805", + uuid: "00000000-0000-4000-8000-000000000701", result: "User turn finished after model switch.", }), ); - yield* awaitUntil( + yield* changes.until( () => events.filter((event) => event.type === "turn.terminal").length === 2, - "user turn terminal after replace", ); assert.isFalse(yield* hasPendingBackgroundWork); - assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - assert.lengthOf(continuationRequests, 1); + assert.lengthOf(continuationRequests, 0); - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000806", - result: "The subagent finished with SUB_BUFFER_REPLACE_DONE.", - }), - ); + yield* Queue.offer(secondProcess, wakeResult); + yield* changes.until(() => continuationRequests.length === 1); + assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-subagent-buffer-replace-cont"), + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), text: "Background task completed.", attachments: [], providerTurnOrdinal: 3, @@ -15429,24 +12366,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil( + yield* changes.until( () => events.filter((event) => event.type === "turn.terminal").length === 3, - "continuation terminal after buffered subagent drain", ); - - const finalSubagent = subagentEvents().at(-1)?.subagent; - assert.equal(finalSubagent?.status, "completed"); - assert.equal(finalSubagent?.result, SUBAGENT_SUMMARY); - assert.equal(finalSubagent?.runId, subagentEvents()[0]?.subagent.runId); - const subagentNodeEvents = events.filter( - ( - event, - ): event is Extract => - event.type === "node.updated" && - event.node.kind === "subagent" && - event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + assert.isTrue( + events.some( + (event) => + event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + assert.isFalse( + events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), ); - assert.equal(subagentNodeEvents.at(-1)?.node.status, "completed"); assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( @@ -15459,6 +12401,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("refuses a thinking change that would kill a running background subagent", () => Effect.scoped( Effect.gen(function* () { + const changes = yield* makeChangeSignal; const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; const fileSystem = yield* FileSystem.FileSystem; @@ -15519,7 +12462,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -15559,7 +12502,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Spawned the subagent in the background.", }), ); - yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); + yield* changes.until(() => terminals().length === 1); const settledTurn = terminals()[0]!; const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; @@ -15600,7 +12543,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: "Switched model.", }), ); - yield* awaitUntil(() => terminals().length === 2, "switched turn terminal"); + yield* changes.until(() => terminals().length === 2); yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, @@ -15621,6 +12564,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #20413 an unreadable append file must not tear down the running process it.effect("keeps the live process when its replacement cannot read the append file", () => Effect.scoped( Effect.gen(function* () { @@ -15705,198 +12649,66 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-append-replacement-a"), - text: "What is the sentinel?", - attachments: [], - runtimePolicy, - }), - ); - yield* Queue.offer( - processQueues[0]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a01", - result: "File sentinel", - }), - ); - yield* Deferred.await(firstTurnTerminal); - - yield* fileSystem.remove(appendFile); - // A thinking change cannot apply live, so this turn must replace the process. - const refused = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-append-replacement-b"), - text: "Switch model.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: CLAUDE_TEST_RESTART_MODEL_SELECTION, - runtimePolicy, - }), - ) - .pipe(Effect.flip); - assert.equal( - makeProviderFailure({ cause: refused, class: "provider_error" }).message, - new ClaudeAdapterV2.ClaudeAppendSystemPromptFileError({ path: appendFile }).message, - ); - assert.lengthOf(processQueues, 1); - assert.equal(closes, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect( - "keeps the process and its roster when a model change meets a running background shell", - () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-replace-open-fail-", - }); - let openCount = 0; - const processQueues: Array> = []; - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - continuationRequests: { - offer: () => Effect.void, - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => { - openCount += 1; - if (openCount === 2) { - return Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced replacement open failure", - }), - ); - } - return Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }); - }, - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-replace-open-fail"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; + attemptId: RunAttemptId.make("attempt-claude-append-replacement-a"), + text: "What is the sentinel?", + attachments: [], + runtimePolicy, + }), + ); + yield* Queue.offer( + processQueues[0]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a01", + result: "File sentinel", + }), + ); + yield* Deferred.await(firstTurnTerminal); - yield* runtime.startTurn( + yield* fileSystem.remove(appendFile); + // A thinking change cannot apply live, so this turn must replace the process. + const refused = yield* runtime + .startTurn( makeClaudeTestTurnInput({ threadId, - providerThread, + providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), - text: "Run the build in the background.", + attemptId: RunAttemptId.make("attempt-claude-append-replacement-b"), + text: "Switch model.", attachments: [], + providerTurnOrdinal: 2, + modelSelection: CLAUDE_TEST_RESTART_MODEL_SELECTION, + runtimePolicy, }), - ); - assert.equal(processQueues.length, 1); - yield* Queue.offer(processQueues[0]!, wakeTaskStarted); - yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* awaitUntil( - () => events.some((event) => event.type === "turn.terminal"), - "first turn terminal", - ); - assert.isTrue(yield* hasPendingBackgroundWork); - - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), - text: "Replace process but fail open.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - assert.equal(openCount, 1); - assert.isTrue(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), + ) + .pipe(Effect.flip); + assert.equal( + makeProviderFailure({ cause: refused, class: "provider_error" }).message, + new ClaudeAdapterV2.ClaudeAppendSystemPromptFileError({ path: appendFile }).message, + ); + assert.lengthOf(processQueues, 1); + assert.equal(closes, 0); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ), ); it.effect( - "clears buffered wake and continuation state when same-native-thread replacement open fails", + "keeps the process and its roster when a model change meets a running background shell", () => Effect.scoped( Effect.gen(function* () { + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-replace-open-fail-wake-", + prefix: "t3-claude-v2-replace-open-fail-", }); let openCount = 0; const processQueues: Array> = []; const events: Array = []; - const continuationRequests: Array = - []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -15907,10 +12719,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { crypto: yield* Crypto.Crypto, idAllocator, continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), + offer: () => Effect.void, }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), @@ -15945,12 +12754,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { assertComplete: Effect.void, }, }); - const threadId = ThreadId.make("thread-claude-replace-open-fail-wake"); + const threadId = ThreadId.make("thread-claude-replace-open-fail"); const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-replace-open-fail-wake", - ), + providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); @@ -15963,7 +12770,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.sync(() => { events.push(event); - }), + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); @@ -15980,20 +12787,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-a"), + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), text: "Run the build in the background.", attachments: [], }), ); + assert.equal(processQueues.length, 1); yield* Queue.offer(processQueues[0]!, wakeTaskStarted); yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 1, - "first turn terminal", - ); - yield* Queue.offer(processQueues[0]!, wakeNotification); - yield* Queue.offer(processQueues[0]!, wakeAssistant); - yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request"); + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); assert.isTrue(yield* hasPendingBackgroundWork); const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; @@ -16003,7 +12805,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-b"), + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), text: "Replace process but fail open.", attachments: [], providerTurnOrdinal: 2, @@ -16012,73 +12814,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - assert.isFalse(yield* hasPendingBackgroundWork); - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-wake-c"), - text: "Retry after the failed replacement.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - const retryProcess = processQueues[1]!; - const retryTaskId = "task-wake-build-after-retry"; - yield* Queue.offer( - retryProcess, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: retryTaskId, - tool_use_id: "toolu-wake-build-after-retry", - description: "npm run build after retry", - is_backgrounded: true, - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000901", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - retryProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Kicked off the retry build in the background.", - }), - ); - yield* awaitUntil( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - "retry turn terminal", - ); - yield* Queue.offer( - retryProcess, - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: retryTaskId, - tool_use_id: "toolu-wake-build-after-retry", - status: "completed", - output_file: "/tmp/task-wake-build-after-retry.log", - summary: "Retry build completed successfully", - uuid: "00000000-0000-4000-8000-000000000903", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - retryProcess, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000904", - text: "The retry build has finished.", - }), - ); - yield* awaitUntil( - () => continuationRequests.length === 2, - "continuation request after retry", - ); - assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); + assert.equal(openCount, 1); + assert.isTrue(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -16087,84 +12824,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("does not invent process reset state on a first-ever failed open", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-first-open-fail-", - }); - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - crypto: yield* Crypto.Crypto, - idAllocator, - continuationRequests: { - offer: () => Effect.void, - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced first open failure", - }), - ), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-first-open-fail"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-first-open-fail"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }), - ), - Effect.forkScoped, - ); - const now = yield* DateTime.now; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-first-open-fail"), - text: "First open fails.", - attachments: [], - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - assert.lengthOf(providerThreadRosterEvents(events), 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - const WORKFLOW_TASK_ID = "task-workflow-run"; const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run"; const makeWorkflowHarnessWithOptions = ( @@ -16359,10 +13018,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* input.harness.offerAndWait( workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }), ); - yield* awaitUntil( - () => workflowMemberEvents(input.harness.events, 2).length === 1, - "workflow members projected", - ); + yield* input.harness.until(() => workflowMemberEvents(input.harness.events, 2).length === 1); }); it.effect.each(["Agent", "Workflow"] as const)( @@ -16492,16 +13148,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - yield* awaitUntil( - () => - workflowCoordinatorEvents(harness.events).length > 0 && - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === "task-plain-auditor", - ), - "workflow coordinator and auditor projected", - ); const subagentPrompt = (nativeTaskId: string) => { const subagent = harness.events.flatMap((event) => event.type === "subagent.updated" && @@ -16509,7 +13155,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [event.subagent] : [], )[0]; - assert.isDefined(subagent); const message = threadMessages(harness.events, subagent?.childThreadId).find( (event) => event.message.role === "user", )?.message; @@ -16519,95 +13164,33 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.turnItem.type === "user_message" && event.turnItem.messageId === message?.id, ); - return { - text: message?.text, - messageMark: message?.workflowScript, - itemMark: - turnItem?.type === "turn_item.updated" && turnItem.turnItem.type === "user_message" - ? turnItem.turnItem.workflowScript - : undefined, - }; - }; - assert.deepEqual(subagentPrompt(WORKFLOW_TASK_ID), { - text: script, - messageMark: true, - itemMark: true, - }); - // A brief is marked as not a script, so its text is never read as one. - assert.deepEqual(subagentPrompt("task-plain-auditor"), { - text: "Audit the last five commits.", - messageMark: false, - itemMark: false, - }); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("scopes workflow member artifacts to the provider thread", () => - Effect.scoped( - Effect.gen(function* () { - const memberArtifacts: Array> = []; - const memberThreads: Array = []; - for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) { - const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`), - text: "Run the workflow.", - attachments: [], - }), - ); - for (const frame of [ - workflowToolUse, - workflowTaskStarted, - workflowLaunchAck(harness.transcriptDir), - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }), - ]) { - yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId })); - } - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }), - session_id: nativeSessionId, - }), - ); - yield* Queue.take(harness.terminalReceipts); - const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; - assert.isDefined(member); - memberThreads.push(member?.childThreadId); - const ids = harness.events.flatMap((event) => { - if (event.type === "subagent.updated" && event.subagent.id === member?.id) - return [event.subagent.id]; - if (event.type === "node.updated" && event.node.threadId === member?.childThreadId) - return [event.node.id]; - if ( - event.type === "message.updated" && - event.message.threadId === member?.childThreadId - ) - return [event.message.id]; - if ( - event.type === "turn_item.updated" && - event.turnItem.threadId === member?.childThreadId - ) - return [event.turnItem.id]; - return []; - }); - assert.isAtLeast(new Set(ids).size, 6); - memberArtifacts.push(ids); - } - assert.notEqual(memberThreads[0], memberThreads[1]); - assert.deepEqual( - memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)), - [], + return { + text: message?.text, + messageMark: message?.workflowScript, + itemMark: + turnItem?.type === "turn_item.updated" && turnItem.turnItem.type === "user_message" + ? turnItem.turnItem.workflowScript + : undefined, + }; + }; + // The user message item lands after the subagent node, so wait on the item itself. + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).length > 0 && + subagentPrompt(WORKFLOW_TASK_ID).itemMark !== undefined && + subagentPrompt("task-plain-auditor").itemMark !== undefined, ); + assert.deepEqual(subagentPrompt(WORKFLOW_TASK_ID), { + text: script, + messageMark: true, + itemMark: true, + }); + // A brief is marked as not a script, so its text is never read as one. + assert.deepEqual(subagentPrompt("task-plain-auditor"), { + text: "Audit the last five commits.", + messageMark: false, + itemMark: false, + }); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -16852,10 +13435,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { secondState: "queued", }), ); - yield* awaitUntil( - () => coordinatorTurnItems().length > turnItemsBeforeRepeat, - "repeated workflow snapshot", - ); + yield* harness.until(() => coordinatorTurnItems().length > turnItemsBeforeRepeat); assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); @@ -16881,9 +13461,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { model: "claude-sonnet-4-6", }), ); - yield* awaitUntil( - () => coordinatorTurnItems().length > turnItemsBeforeMetadata, - "updated workflow member metadata", + // The member's node and app thread pick up the metadata after the coordinator item. + yield* harness.until( + () => + coordinatorTurnItems().length > turnItemsBeforeMetadata && + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title === "alpha:review" && + firstThreadEvents().at(-1)?.appThread.title === "alpha:review", ); assert.equal( workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, @@ -17019,557 +13602,54 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), - ); - yield* Queue.take(harness.terminalReceipts); - const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; - const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; - assert.isDefined(member); - assert.equal(member?.status, "running"); - assert.isTrue( - harness.events.some( - (event) => - event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, - ), - ); - assert.deepEqual( - threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), - ["Reply with exactly: A1", "A1 from the transcript"], - ); - assert.isDefined(coordinator?.childThreadId); - const recoveredThreads = harness.events.filter( - (event) => - event.type === "app_thread.created" && - event.appThread.id === coordinator?.childThreadId, - ); - assert.lengthOf(recoveredThreads, 1); - const recovered = recoveredThreads[0]; - assert.equal(recovered?.type, "app_thread.created"); - if (recovered?.type !== "app_thread.created") throw new Error("Expected recovered thread"); - assert.isTrue(recovered.createOnly); - assert.deepEqual(recovered.appThread.lineage, { - parentThreadId: harness.threadId, - relationshipToParent: "subagent", - rootThreadId: harness.threadId, - }); - assert.equal(recovered.appThread.createdBy, "agent"); - assert.equal(recovered.appThread.creationSource, "provider"); - assert.isBelow( - harness.events.indexOf(recovered), - harness.events.findIndex( - (event) => - event.type === "subagent.updated" && - event.subagent.childThreadId === coordinator?.childThreadId, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("does not republish a workflow roster on progress-only frames", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"), - text: "Run the alpha workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" }); - const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length; - const offerProgressOnly = (index: number) => - Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: WORKFLOW_TASK_ID, - tool_use_id: WORKFLOW_TOOL_USE_ID, - description: "Member is working", - uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`, - session_id: WAKE_NATIVE_SESSION, - }), - ); - - const inTurnCount = coordinatorCount(); - for (let index = 0; index < 5; index++) yield* offerProgressOnly(index); - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }), - ); - yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot"); - assert.equal(coordinatorCount(), inTurnCount + 1); - - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001122", - result: "Launched the workflow.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - - const betweenTurnCount = coordinatorCount(); - for (let index = 5; index < 10; index++) yield* offerProgressOnly(index); - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }), - ); - yield* awaitUntil( - () => coordinatorCount() > betweenTurnCount, - "between-turn workflow snapshot", - ); - assert.equal(coordinatorCount(), betweenTurnCount + 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () => - Effect.scoped( - Effect.gen(function* () { - const clock = yield* Clock.Clock; - const cleanupPaused = yield* Deferred.make(); - const releaseCleanup = yield* Deferred.make(); - let readsUntilPause = 0; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarnessWithOptions({ - close: Queue.shutdown, - freshQueueOnReopen: true, - }); - const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old"); - const oldBash = "toolu-workflow-stale-cleanup-old-bash"; - const newBash = "toolu-workflow-stale-cleanup-new-bash"; - const resumeTool = "toolu-workflow-stale-cleanup-resume"; - const toolUpdates = (nativeId: string) => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === nativeId - ? [event.turnItem] - : [], - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: oldAttempt, - text: "Run the workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" }); - for (const frame of makeSubagentAssistantFrames({ - parentToolUseId: WORKFLOW_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001151", - bashToolUseId: oldBash, - })) { - yield* harness.offerAndWait(frame); - } - yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running"); - readsUntilPause = 2; - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${oldAttempt}`, - }), - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(cleanupPaused); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(stop); - yield* Queue.take(harness.terminalReceipts); - const oldToolCount = toolUpdates(oldBash).length; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"), - text: "Resume the workflow.", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - const resumedQueue = harness.processQueues[1]!; - yield* Queue.offer( - resumedQueue, - claudeSdkFrame({ - ...workflowTaskStarted, - tool_use_id: resumeTool, - prompt: "Resume the workflow.", - uuid: "00000000-0000-4000-8000-000000001152", - }), - ); - for (const frame of makeSubagentAssistantFrames({ - parentToolUseId: resumeTool, - uuid: "00000000-0000-4000-8000-000000001153", - bashToolUseId: newBash, - })) { - yield* Queue.offer(resumedQueue, frame); - } - yield* awaitUntil( - () => toolUpdates(newBash).length === 1, - "resumed workflow tool running", - ); - yield* Deferred.succeed(releaseCleanup, undefined); - yield* Queue.take(harness.terminalReceipts); - assert.lengthOf(toolUpdates(oldBash), oldToolCount); - assert.deepEqual( - toolUpdates(newBash).map((item) => item.status), - ["running"], - ); - yield* Queue.offer( - resumedQueue, - makeSubagentToolResultFrame({ - parentToolUseId: resumeTool, - uuid: "00000000-0000-4000-8000-000000001154", - toolUseId: newBash, - }), - ); - yield* awaitUntil( - () => toolUpdates(newBash).at(-1)?.status === "completed", - "resumed workflow tool completed", - ); - assert.equal(toolUpdates(newBash).at(-1)?.output, "ok"); - }).pipe( - Effect.provideService(Clock.Clock, { - ...clock, - currentTimeMillis: Effect.gen(function* () { - if (readsUntilPause > 0 && --readsUntilPause === 0) { - yield* Deferred.succeed(cleanupPaused, undefined); - yield* Deferred.await(releaseCleanup); - } - return yield* clock.currentTimeMillis; - }), - }), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["root result", "query close"] as const)( - "keeps a waiting continuation inactive until replay can handle %s", - (arrival) => - Effect.scoped( - Effect.gen(function* () { - const digestStarted = yield* Deferred.make(); - const releaseDigest = yield* Deferred.make(); - const closeStarted = yield* Deferred.make(); - let pauseDigest = false; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarnessWithOptions({ - freshQueueOnReopen: true, - close: (messages) => - Deferred.succeed(closeStarted, undefined).pipe( - Effect.andThen(Queue.shutdown(messages)), - ), - }); - const start = (attemptId: string, ordinal: number, continuation: boolean) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: DateTime.makeUnsafe(0), - attemptId: RunAttemptId.make(attemptId), - providerTurnOrdinal: ordinal, - text: "Continue working.", - attachments: [], - ...(continuation - ? { messageCreatedBy: "agent", messageCreationSource: "provider" } - : {}), - }), - ); - yield* start("attempt-workflow-waiting-launch", 1, false); - yield* launchWorkflow({ - harness, - snapshotUuid: "00000000-0000-4000-8000-000000003200", - }); - const fileSystem = yield* FileSystem.FileSystem; - const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live"); - yield* fileSystem.makeDirectory(liveTranscriptDir); - yield* fileSystem.writeFileString( - NodePath.join(liveTranscriptDir, "agent-a1.jsonl"), - '{"type":"assistant","uuid":"wf-line-2","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"Live member answer"}]}}\n', - ); - const liveTaskId = "task-workflow-live-while-waiting"; - const liveToolUseId = "toolu-workflow-live-while-waiting"; - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }], - }, - parent_tool_use_id: null, - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000003201", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - task_id: liveTaskId, - tool_use_id: liveToolUseId, - uuid: "00000000-0000-4000-8000-000000003202", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." }, - ], - }, - tool_use_result: { - taskType: "local_workflow", - runId: "wf_live_while_waiting", - transcriptDir: liveTranscriptDir, - }, - parent_tool_use_id: null, - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000003203", - }), - ); - const liveProgress = (state: "start" | "done", uuid: string) => - claudeSdkFrame({ - ...workflowSnapshot({ state, uuid }), - task_id: liveTaskId, - tool_use_id: liveToolUseId, - }); - yield* harness.offerAndWait( - liveProgress("start", "00000000-0000-4000-8000-000000003204"), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000003205", - result: "Launched both workflows.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000003206", - status: "completed", - }), - ); - yield* Queue.take(harness.continuationReceipts); - yield* harness.offerAndWait(wakeTurnInit); - pauseDigest = true; - const progress = yield* harness - .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207")) - .pipe(Effect.forkScoped); - yield* Deferred.await(digestStarted); - const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe( - Effect.forkScoped({ startImmediately: true }), - ); - if (arrival === "query close") { - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: "turn:attempt-workflow-waiting-replay", - }), - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(closeStarted); - yield* Deferred.succeed(releaseDigest, undefined); - yield* Fiber.join(progress); - yield* Fiber.join(stop); - assert.isTrue(Exit.isFailure(yield* Fiber.await(replay))); - } else { - const offered = yield* Deferred.make(); - const result = yield* harness - .offerAndWait(wakeResult, offered) - .pipe(Effect.forkScoped); - yield* Deferred.await(offered); - yield* Deferred.succeed(releaseDigest, undefined); - yield* Fiber.join(progress); - yield* Fiber.join(replay); - yield* Fiber.join(result); - yield* Queue.take(harness.terminalReceipts); - assert.lengthOf(harness.continuationRequests, 1); - } - yield* start("attempt-workflow-waiting-next-user", 3, false); - }).pipe( - Effect.provideService(WorkflowAgentTranscriptReadObserver, () => - Effect.gen(function* () { - if (pauseDigest) { - pauseDigest = false; - yield* Deferred.succeed(digestStarted, undefined); - yield* Deferred.await(releaseDigest); - } - }), - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.isDefined(member); + assert.equal(member?.status, "running"); + assert.isTrue( + harness.events.some( + (event) => + event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, ), - ), - ), - ); - - it.effect.each(["registry", "transcript"] as const)( - "does not replay stale idle progress over a completed workflow after awaiting %s", - (boundary) => - Effect.scoped( - Effect.gen(function* () { - const clock = yield* Clock.Clock; - const progressRead = yield* Deferred.make(); - const releaseProgress = yield* Deferred.make(); - let pauseNextRead = false; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"), - text: "Run the workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ - harness, - snapshotUuid: "00000000-0000-4000-8000-000000001060", - }); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001061", - result: "Launched.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001062", - status: "completed", - }), - ); - if (boundary === "registry") { - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }), - ); - } - const continuationInput = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"), - text: "Continue.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }); - pauseNextRead = true; - const progress = yield* harness - .offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001064", - state: boundary === "transcript" ? "done" : "start", - secondState: "start", - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(progressRead); - const continuation = yield* harness.runtime - .startTurn(continuationInput) - .pipe(Effect.forkScoped); - yield* Deferred.succeed(releaseProgress, undefined); - yield* Fiber.join(progress); - yield* Fiber.join(continuation); - if (boundary === "registry") { - yield* Queue.take(harness.terminalReceipts); - } else { - const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; - while (true) { - const event = yield* Queue.take(harness.subagentReceipts); - if (event.subagent.id === lastMemberId && event.subagent.status === "completed") - break; - } - } - if (boundary === "transcript") { - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }), - ); - yield* Queue.take(harness.terminalReceipts); - } - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, - "completed", - ); - const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; - assert.equal(member?.status, "completed"); - const root = harness.events.findLast( - (event) => - event.type === "node.updated" && event.node.threadId === member?.childThreadId, - ); - assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed"); - const item = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, - ); - assert.equal( - item?.type === "turn_item.updated" ? item.turnItem.status : null, - "completed", - ); - }).pipe( - Effect.provideService(Clock.Clock, { - ...clock, - currentTimeMillis: Effect.gen(function* () { - if (boundary === "registry" && pauseNextRead) { - pauseNextRead = false; - yield* Deferred.succeed(progressRead, undefined); - yield* Deferred.await(releaseProgress); - } - return yield* clock.currentTimeMillis; - }), - }), - Effect.provideService(WorkflowAgentTranscriptReadObserver, () => - Effect.gen(function* () { - if (boundary === "transcript" && pauseNextRead) { - pauseNextRead = false; - yield* Deferred.succeed(progressRead, undefined); - yield* Deferred.await(releaseProgress); - } - }), - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + assert.deepEqual( + threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 from the transcript"], + ); + assert.isDefined(coordinator?.childThreadId); + const recoveredThreads = harness.events.filter( + (event) => + event.type === "app_thread.created" && + event.appThread.id === coordinator?.childThreadId, + ); + assert.lengthOf(recoveredThreads, 1); + const recovered = recoveredThreads[0]; + assert.equal(recovered?.type, "app_thread.created"); + if (recovered?.type !== "app_thread.created") throw new Error("Expected recovered thread"); + assert.isTrue(recovered.createOnly); + assert.deepEqual(recovered.appThread.lineage, { + parentThreadId: harness.threadId, + relationshipToParent: "subagent", + rootThreadId: harness.threadId, + }); + assert.equal(recovered.appThread.createdBy, "agent"); + assert.equal(recovered.appThread.creationSource, "provider"); + assert.isBelow( + harness.events.indexOf(recovered), + harness.events.findIndex( + (event) => + event.type === "subagent.updated" && + event.subagent.childThreadId === coordinator?.childThreadId, ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ), ); it.effect.each([ @@ -18005,320 +14085,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping))); const retry = yield* harness.runtime.stopSubagent!({ providerThread: harness.providerThread, - nativeTaskId: WORKFLOW_TASK_ID, - }).pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("2 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(retry))); - } - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); - for (const index of [1, 2]) { - assert.equal( - workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, - settled, - ); - } - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([ - ["stopped", false, false, false], - ["failed", false, false, false], - ["completed", false, false, false], - ["stopped", true, false, false], - ["failed", true, false, false], - ["completed", true, false, false], - ["interrupted", false, false, false], - ["interrupted", true, false, false], - ["interrupt_timeout", true, false, false], - ["stop_buffered", true, false, false], - ["stop_buffered", true, false, "buffered"], - ["stop_buffered", true, false, "live"], - ["drain_buffered", true, false, "live"], - ["drain_buffered", true, false, "live_after_buffered"], - ["query_failed", false, false, false], - ["query_failed", true, false, false], - ["query_failed", true, false, "buffered"], - ["query_failed", true, true, false], - ] as const)( - "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s", - ([status, betweenTurns, afterFailedTurn, resumed]) => - Effect.scoped( - Effect.gen(function* () { - const interrupted = status === "interrupted" || status === "interrupt_timeout"; - const liveResume = resumed === "live" || resumed === "live_after_buffered"; - const expected = - status === "drain_buffered" - ? resumed === "live_after_buffered" - ? "completed" - : "running" - : status === "stopped" || interrupted || (status === "stop_buffered" && resumed) - ? "cancelled" - : status === "query_failed" - ? "failed" - : status === "stop_buffered" - ? "completed" - : status; - const harness = yield* makeWorkflowHarnessWithOptions({ - close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown, - }); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-workflow-terminal"), - text: "Run the workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" }); - if (betweenTurns) { - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001013", - result: "Launched.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - if (afterFailedTurn || status === "interrupt_timeout") { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-unrelated-failed"), - providerTurnOrdinal: 2, - text: "Unrelated follow-up.", - attachments: [], - }), - ); - if (afterFailedTurn) { - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001015", - result: "Unrelated follow-up failed.", - terminalReason: "api_error", - isError: true, - }), - ); - yield* Queue.take(harness.terminalReceipts); - } - } - if (status === "query_failed") { - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001014", - state: "start", - label: "Updated between turns", - }), - ); - } - } - const bufferedCompletion = - status === "stop_buffered" || status === "drain_buffered" || resumed; - if ((!interrupted && status !== "query_failed") || resumed) - yield* Queue.offer( - harness.sdkMessages, - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001011", - status: bufferedCompletion ? "completed" : status, - }), - ); - if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) { - yield* Queue.take(harness.continuationReceipts); - } - if (resumed) { - if (resumed === "live_after_buffered") { - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - tool_use_id: "toolu-workflow-earlier-resume", - prompt: "Earlier buffered resume.", - uuid: "00000000-0000-4000-8000-000000001019", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001020", - status: "completed", - }), - tool_use_id: "toolu-workflow-earlier-resume", - }), - ); - } - if (liveResume) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-workflow-live-resume"), - providerTurnOrdinal: 2, - text: "Continue the workflow.", - attachments: [], - }), - ); - } - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - tool_use_id: "toolu-workflow-resume", - prompt: "Continue the workflow.", - uuid: "00000000-0000-4000-8000-000000001016", - }), - ); - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001017", - state: "start", - attempt: 2, - label: "Updated between turns", - }), - ); - if (liveResume) { - if (resumed === "live_after_buffered") { - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001021", - status: "completed", - }), - tool_use_id: "toolu-workflow-resume", - summary: "Latest workflow result.", - }), - ); - } - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001018", - result: "Resumed the workflow.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - } - } - if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), - text: "Workflow finished.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - } - if (interrupted || status === "stop_buffered") { - const interrupt = harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: - status === "interrupt_timeout" - ? "turn:attempt-unrelated-failed" - : liveResume - ? "turn:attempt-workflow-live-resume" - : "turn:attempt-workflow-terminal", - }), - ...(betweenTurns ? { requestRuntimeRestart: true } : {}), - }); - if (status === "interrupt_timeout") { - const stop = yield* interrupt.pipe(Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(stop); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - } else { - yield* interrupt; - } - } else if (status === "query_failed") { - yield* Queue.shutdown(harness.sdkMessages); - } else if (status === "drain_buffered") { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), - text: "Workflow finished.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, - expected, - ); - } else { - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001012", - result: "Finished.", - }), - ); - } - if ( - betweenTurns && - (status === "query_failed" || interrupted || status === "stop_buffered") - ) { - const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; - while (true) { - const update = yield* Queue.take(harness.subagentReceipts); - if (update.subagent.id === lastMemberId && update.subagent.status !== "running") - break; - } - assert.isFalse(yield* harness.hasPendingBackgroundWork); - if (status === "query_failed") { - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, - "Updated between turns", - ); - } - } else { - yield* Queue.take(harness.terminalReceipts); - } - assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected); - if (status === "drain_buffered") - assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running"); - if (resumed === "live_after_buffered") { - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, - "Latest workflow result.", - ); - } - if (resumed) { - assert.notEqual( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, - "Workflow finished.", - ); + nativeTaskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit, Effect.forkScoped); + yield* TestClock.adjust("2 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(retry))); } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); for (const index of [1, 2]) { - const updates = workflowMemberEvents(harness.events, index); - const member = updates.at(-1)?.subagent; - assert.equal(member?.status, expected); - assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt); - const root = harness.events.findLast( - (event) => - event.type === "node.updated" && event.node.threadId === member?.childThreadId, + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + settled, ); - assert.equal(root?.type === "node.updated" ? root.node.status : null, expected); } }).pipe( Effect.provide( @@ -18328,74 +14106,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("reads a transcript that appears after a terminal workflow notification", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"), - text: "Run the alpha workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" }); - const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }), - ); - yield* awaitUntil( - () => threadMessages(harness.events, secondThreadId).length === 2, - "workflow member excerpt", - ); - const notification = workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001032", - status: "completed", - }); - yield* Queue.offer(harness.sdkMessages, notification); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID && - event.turnItem.status === "completed", - ), - "terminal workflow notification", - ); - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), - '{"type":"assistant","uuid":"wf-line-3","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n', - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }), - ); - yield* awaitUntil( - () => - threadMessages(harness.events, secondThreadId).at(-1)?.message.text === - "A2 final answer", - "late terminal transcript answer", - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }), - ); - yield* Queue.take(harness.terminalReceipts); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => Effect.scoped( Effect.gen(function* () { @@ -18458,10 +14168,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), ); - yield* awaitUntil( - () => threadMessages(harness.events, secondThreadId).length === 2, - "settled workflow member answers", - ); + yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2); const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; assert.deepEqual( @@ -18491,14 +14198,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait( workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), ); - yield* awaitUntil( + yield* harness.until( () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, - "repeated settled workflow snapshot", ); - yield* awaitUntil( + yield* harness.until( () => threadMessages(harness.events, firstThreadId).at(-1)?.message.text === "A1 continued", - "continued workflow transcript answer", ); assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); @@ -18510,10 +14215,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait( workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), ); - yield* awaitUntil( - () => threadMessages(harness.events, secondThreadId).length === 3, - "late workflow transcript answer", - ); + yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 3); assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); assert.equal( threadMessages(harness.events, secondThreadId).at(-1)?.message.text, @@ -18539,9 +14241,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { lastProgressAt: 4432, }), ); - yield* awaitUntil( - () => currentAssistantAnswers().join("\n\n").includes("Second answer"), - "two-turn workflow member answer", + yield* harness.until(() => + currentAssistantAnswers().join("\n\n").includes("Second answer"), ); assert.deepEqual(currentAssistantAnswers().slice(-2), ["First answer", "Second answer"]); const completedMember = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; @@ -18582,11 +14283,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { attempt: 3, }), ); - yield* awaitUntil( + yield* harness.until( () => threadMessages(harness.events, firstThreadId).at(-1)?.message.text === "A1 retry answer", - "retried workflow member answer", ); assert.equal(currentAssistantAnswers().at(-1), "A1 retry answer"); NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); @@ -18598,11 +14298,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { attempt: 4, }), ); - yield* awaitUntil( + yield* harness.until( () => workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] ?.attempt === 4, - "fourth workflow member attempt", ); const answersBeforeEmptyAttempt = currentAssistantAnswers(); yield* harness.offerAndWait( @@ -18990,153 +14689,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - { before: "nothing" }, - { before: "text" }, - { before: "a line without a stop reason" }, - ] as const)( - "waits for a settled workflow member's reply to reach its transcript ($before before it)", - ({ before }) => - Effect.scoped( - Effect.gen(function* () { - // Claude Code flushes transcript lines every 100 ms, so a member's reply - // can land after the frame that settles it (seen with CLI 2.1.301). - let appendLateReply: (() => void) | undefined; - const lateReplyWritten = yield* Deferred.make(); - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const transcriptPath = (agentId: string) => - NodePath.join(harness.transcriptDir, `agent-${agentId}.jsonl`); - // A member often says what it is about to do before its first tool call. - const toolCall = (agentId: string) => - memberAssistantLine( - `line-${agentId}-bash`, - `msg_${agentId}`, - [ - ...(before === "text" - ? [{ type: "text", text: `${agentId} lists the files.` }] - : []), - { - type: "tool_use", - id: `toolu_${agentId}`, - name: "Bash", - input: { command: "ls" }, - }, - ], - "tool_use", - ) + - memberToolResultLine(`line-${agentId}-result`, `toolu_${agentId}`, "README.md") + - // A line from a writer that leaves the field out entirely. - (before === "a line without a stop reason" - ? memberAssistantLine(`line-${agentId}-thinking`, `msg_${agentId}_reply`, [ - { type: "thinking", thinking: "Summarize the file.", signature: "sig" }, - ]) - : ""); - const early = (agentId: string) => - before === "text" ? [`${agentId} lists the files.`] : []; - const reply = (agentId: string) => - memberAssistantLine( - `line-${agentId}-reply`, - `msg_${agentId}_reply`, - [{ type: "text", text: `${agentId} found README.md` }], - "end_turn", - ); - NodeFS.writeFileSync(transcriptPath("a1"), toolCall("a1")); - NodeFS.writeFileSync(transcriptPath("a2"), toolCall("a2")); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-workflow-member-late-flush"), - text: "Run the alpha workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ - harness, - snapshotUuid: "00000000-0000-4000-8000-000000001395", - }); - const texts = (index: number) => - threadMessages( - harness.events, - workflowMemberEvents(harness.events, index)[0]?.subagent.childThreadId, - ).map((event) => event.message.text); - - // The first member settles while the workflow runs on. - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001396", - state: "done", - secondState: "start", - }), - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, - "completed", - ); - assert.deepEqual( - texts(1), - ["Reply with exactly: A1", ...early("a1")], - "no excerpt before the reply", - ); - NodeFS.appendFileSync(transcriptPath("a1"), reply("a1")); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001397", state: "done" }), - ); - assert.deepEqual(texts(1), [ - "Reply with exactly: A1", - ...early("a1"), - "a1 found README.md", - ]); - assert.deepEqual(texts(2), ["Reply with exactly: A2", ...early("a2")]); - - // The workflow ends right after the second member, before its reply lands. - appendLateReply = () => NodeFS.appendFileSync(transcriptPath("a2"), reply("a2")); - const notified = yield* harness - .offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001398", - status: "completed", - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(lateReplyWritten); - for (let tick = 0; tick < 20 && texts(2).at(-1) !== "a2 found README.md"; tick += 1) { - yield* TestClock.adjust("100 millis"); - } - yield* Fiber.join(notified); - // The reply is read with real file I/O after the clock moves, and its - // events reach the collector fiber after the frame counts as processed. - yield* awaitUntil(() => texts(2).at(-1) === "a2 found README.md", "a2 reply"); - assert.deepEqual(texts(2), [ - "Reply with exactly: A2", - ...early("a2"), - "a2 found README.md", - ]); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001399", result: "Done." }), - ); - yield* Queue.take(harness.terminalReceipts); - }).pipe( - Effect.provideService(WorkflowAgentTranscriptReadObserver, (read) => { - // Lands just after the read that would have settled the member. - if (read.agentId !== "a2" || appendLateReply === undefined) return Effect.void; - const append = appendLateReply; - appendLateReply = undefined; - append(); - return Deferred.succeed(lateReplyWritten, undefined).pipe(Effect.asVoid); - }), - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - it.effect("re-reads a workflow member's transcript after a restart without new items", () => Effect.scoped( Effect.gen(function* () { @@ -19186,283 +14738,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - { backgrounding: "succeeds" }, - { backgrounding: "fails" }, - { backgrounding: "never answers" }, - { backgrounding: "answers after the steer went out" }, - { backgrounding: "answers after the turn ended" }, - { backgrounding: "answers after the subagent ran again" }, - ] as const)( - "moves the root's foreground subagents to the background before a steer ($backgrounding)", - ({ backgrounding }) => - Effect.scoped( - Effect.gen(function* () { - const FOREGROUND_TOOL_USE_ID = "toolu-steer-foreground"; - const NESTED_TOOL_USE_ID = "toolu-steer-nested"; - const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; - const calls: Array = []; - let beforeAnswer: Effect.Effect = Effect.void; - const harness = yield* makeWakeHarnessWithOptions({ - backgroundTasks: (toolUseId) => - Effect.sync(() => calls.push(toolUseId)).pipe( - Effect.andThen( - backgrounding === "answers after the turn ended" || - backgrounding === "answers after the subagent ran again" - ? Effect.suspend(() => beforeAnswer).pipe(Effect.as(true)) - : backgrounding === "fails" - ? Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "backgroundTasks", - cause: new Error("Background tasks are disabled in this session."), - }), - ) - : backgrounding === "never answers" - ? Effect.never - : backgrounding === "answers after the steer went out" - ? Effect.sleep("6 seconds").pipe(Effect.as(true)) - : Effect.succeed(true), - ), - ), - }); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attemptId = RunAttemptId.make("attempt-steer-foreground-subagent"); - const input = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId, - text: "Audit the repo with a subagent.", - attachments: [], - }); - yield* harness.runtime.startTurn(input); - if (backgrounding === "answers after the turn ended") { - beforeAnswer = Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a10", result: "Done." }), - ).pipe( - Effect.andThen(awaitUntil(() => harness.terminalEvents().length === 1, "turn end")), - ); - } - const foregroundTaskStarted = (task: { - readonly spawnDepth?: number; - readonly taskId: string; - readonly toolUseId: string; - readonly uuid: string; - }) => - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: task.taskId, - tool_use_id: task.toolUseId, - description: task.taskId, - subagent_type: "general-purpose", - is_backgrounded: false, - task_type: "local_agent", - prompt: "Audit.", - ...(task.spawnDepth === undefined ? {} : { spawn_depth: task.spawnDepth }), - uuid: task.uuid, - session_id: WAKE_NATIVE_SESSION, - }); - for (const frame of [ - foregroundTaskStarted({ - taskId: "task-steer-finished", - toolUseId: FINISHED_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a01", - }), - makeSubagentNotificationFrame({ - taskId: "task-steer-finished", - toolUseId: FINISHED_TOOL_USE_ID, - summary: "Finished before the steer.", - uuid: "00000000-0000-4000-8000-000000000a02", - }), - foregroundTaskStarted({ - taskId: "task-steer-foreground", - toolUseId: FOREGROUND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a03", - }), - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_steer_nested_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: NESTED_TOOL_USE_ID, - name: "Agent", - input: { description: "Nested check", prompt: "Check." }, - }, - ], - }, - parent_tool_use_id: FOREGROUND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a04", - session_id: WAKE_NATIVE_SESSION, - }), - foregroundTaskStarted({ - taskId: "task-steer-nested", - toolUseId: NESTED_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a05", - }), - foregroundTaskStarted({ - taskId: "task-steer-deep", - toolUseId: "toolu-steer-deep", - spawnDepth: 2, - uuid: "00000000-0000-4000-8000-000000000a11", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - const latestSubagent = (taskId: string) => - harness.events - .flatMap((event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === taskId - ? [event.subagent] - : [], - ) - .at(-1); - yield* awaitUntil( - () => - latestSubagent("task-steer-nested")?.status === "running" && - latestSubagent("task-steer-deep")?.status === "running" && - latestSubagent("task-steer-foreground")?.status === "running" && - latestSubagent("task-steer-finished")?.status === "completed", - "subagents registered", - ); - - const offered = harness.offeredMessages.length; - const steer = harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-steer-foreground-subagent"), - text: "Also check the docs.", - attachments: [], - }, - }); - const RERUN_TOOL_USE_ID = "toolu-steer-rerun"; - if (backgrounding === "answers after the subagent ran again") { - beforeAnswer = harness - .offerAndWait( - makeSubagentNotificationFrame({ - taskId: "task-steer-foreground", - toolUseId: FOREGROUND_TOOL_USE_ID, - summary: "First run done.", - uuid: "00000000-0000-4000-8000-000000000a12", - }), - ) - .pipe( - Effect.andThen( - harness.offerAndWait( - foregroundTaskStarted({ - taskId: "task-steer-foreground", - toolUseId: RERUN_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a13", - }), - ), - ), - ); - } - const steering = yield* Effect.forkScoped(steer); - if ( - backgrounding === "never answers" || - backgrounding === "answers after the steer went out" - ) { - yield* awaitUntil(() => calls.length === 1, "background request"); - assert.equal(harness.offeredMessages.length, offered); - yield* TestClock.adjust("5 seconds"); - } - const steered = yield* Fiber.await(steering); - - assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); - if (backgrounding === "answers after the turn ended") { - assert.isTrue(Exit.isFailure(steered)); - assert.equal(harness.offeredMessages.length, offered); - return; - } - assert.isTrue(Exit.isSuccess(steered)); - assert.equal(harness.offeredMessages.length, offered + 1); - assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); - if (backgrounding === "never answers") { - for (let repeat = 1; repeat <= 2; repeat++) { - const again = yield* Effect.forkScoped(steer); - for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { - yield* TestClock.adjust("1 second"); - yield* Effect.yieldNow; - } - assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); - assert.equal(harness.offeredMessages.length, offered + 1 + repeat); - } - assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); - } - if (backgrounding === "answers after the steer went out") { - yield* TestClock.adjust("1 second"); - } else if ( - backgrounding !== "succeeds" && - backgrounding !== "answers after the subagent ran again" - ) { - return; - } - const reran = backgrounding === "answers after the subagent ran again"; - - for (const frame of [ - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a06", - result: "", - terminalReason: "aborted_tools", - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a07", result: "Docs too." }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "steered turn terminal"); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-steer-next-turn"), - text: "Anything else?", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - for (const frame of [ - makeSubagentNotificationFrame({ - taskId: "task-steer-foreground", - toolUseId: reran ? RERUN_TOOL_USE_ID : FOREGROUND_TOOL_USE_ID, - summary: "AUDITED", - uuid: "00000000-0000-4000-8000-000000000a08", - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a09", result: "No." }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 2, "next turn terminal"); - yield* Queue.offer(harness.sdkMessages, wakeTurnInit); - yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); - assert.equal( - harness.continuationRequests[0]?.notification?.summary, - reran ? undefined : 'Subagent "task-steer-foreground" finished', - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - describe("native goals", () => { const syntheticFrame = (uuid: string, text: string) => claudeSdkFrame({ @@ -19548,45 +14823,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("keeps a goal active when a hook stops the turn before the goal passes", () => - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const condition = "the deploy succeeds"; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("goal-hook-stop-attempt"), - text: `/goal ${condition}`, - attachments: [], - }), - ); - for (const frame of [ - syntheticFrame("goal-hook-set", `Goal set: ${condition}`), - makeAssistantTextFrame({ uuid: "goal-hook-work", text: "Deploying." }), - makeResultFrame({ - uuid: "goal-hook-result", - result: "Deploying.", - terminalReason: "hook_stopped", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); - assert.deepEqual(goalStatuses(harness.events).at(-1), { - objective: condition, - status: "active", - checks: 0, - }); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - it.effect( "defers goal completion until an ambient subagent finishes and Claude checks again", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b9cf550e4ec3..e41f022b2aaf 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -107,6 +107,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -201,7 +202,7 @@ import { } from "@t3tools/provider-core/server/subagentProjection"; export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); -export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; +const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; function claudeContextWindow(modelSelection: ModelSelection): number | null { return ( @@ -316,6 +317,9 @@ const CLAUDE_WAKE_TURN_START_DEADLINE = "2 minutes"; // reply can land after the progress frame that settles it. Its transcript // stays the source of its answer this long after it settles. const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; +// A steer waits at most this long, from its request, for its foreground +// subagents to move to the background before it is sent. +const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; /** * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's @@ -782,7 +786,7 @@ export function loggedClaudeQueryOptions( }; } -export function makeClaudeAgentSdkProtocolLogger(input: { +function makeClaudeAgentSdkProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; @@ -2294,7 +2298,7 @@ const CLAUDE_KNOWN_TOOL_CLASSIFICATIONS: Record< write: { itemType: "file_change", requestKind: "file-change" }, }; -export function classifyClaudeNativeTool(toolName: string): ClaudeToolClassification { +function classifyClaudeNativeTool(toolName: string): ClaudeToolClassification { const normalizedName = normalizedClaudeToolName(toolName); const known = CLAUDE_KNOWN_TOOL_CLASSIFICATIONS[normalizedName]; return known === undefined @@ -4066,7 +4070,7 @@ export function claudeSdkUserInputAnswers( ); } -export function claudeTodoSteps(input: unknown): ReadonlyArray { +function claudeTodoSteps(input: unknown): ReadonlyArray { const value = typeof input === "object" && input !== null && Reflect.get(input, "type") === "record" ? Reflect.get(input, "value") @@ -4234,7 +4238,7 @@ function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray ({ id: `task-${id}`, ...task })); } -export function claudeProposedPlan(input: unknown): string | null { +function claudeProposedPlan(input: unknown): string | null { const value = typeof input === "object" && input !== null && Reflect.get(input, "type") === "record" ? Reflect.get(input, "value") @@ -10696,6 +10700,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // its result arrives as a task notification like any background agent. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, + deadline: number, ) { const foreground: Array<{ readonly taskId: string; @@ -10751,11 +10756,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( backgroundMovesByToolUseId.set(toolUseId, move); moves.push(move); } - yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); + const remaining = deadline - (yield* Clock.currentTimeMillis); + yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption(Math.max(0, remaining))); }); const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) { + // Measured from the request, not from when the moves are forked: + // building the message and the moves can take clock time a timer + // started later would not count, and the steer would wait past it. + const backgroundDeadline = + (yield* Clock.currentTimeMillis) + BACKGROUND_MOVE_TIMEOUT_MS; const existing = yield* Ref.get(queryContext); if (existing === null) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -10781,7 +10792,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd), }); - yield* backgroundForegroundSubagents(existing); + yield* backgroundForegroundSubagents(existing, backgroundDeadline); // The turn can end while the moves are answered. A steer offered // then would start a new turn the orchestrator never asked for, so // the steer fails and the orchestrator starts the follow-up turn. diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 693e2cbab09f..d5c9686bb8d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -8,7 +8,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -16,77 +15,10 @@ import { vi } from "vite-plus/test"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; -import { - CLAUDE_AGENT_SDK_QUERY_PROTOCOL, - ClaudeAgentSdkQueryRunner, - layerQueryRunner, -} from "./ClaudeAdapterV2.ts"; +import { ClaudeAgentSdkQueryRunner, layerQueryRunner } from "./ClaudeAdapterV2.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); -it.effect("logs successful task stops for replay and omits failed stops", () => - Effect.gen(function* () { - const writes: unknown[] = []; - const stopTask = vi.fn(async (_taskId: string) => {}); - const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({ - stopTask, - } as unknown as ClaudeSdk.Query); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const threadId = ThreadId.make("thread-stop"); - const providerSessionId = ProviderSessionId.make("session-stop"); - yield* Effect.gen(function* () { - const runner = yield* ClaudeAgentSdkQueryRunner; - const session = yield* runner.open({ - threadId, - providerSessionId, - options: { - sessionId: "native-session-stop", - model: "claude-sonnet-4-6", - tools: [], - permissionMode: "default", - }, - }); - writes.length = 0; - yield* session.stopTask("workflow-task"); - assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]); - assert.deepEqual(writes, [ - { - provider: "claudeAgent", - protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL, - kind: "protocol", - providerSessionId, - event: { - direction: "outgoing", - stage: "decoded", - payload: { type: "query.stop_task", taskId: "workflow-task" }, - }, - }, - ]); - stopTask.mockRejectedValueOnce(new Error("Stop failed")); - assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task")))); - assert.equal(writes.length, 1); - }).pipe( - Effect.provide( - layerQueryRunner.pipe( - Layer.provide( - Layer.succeed(ProviderEventLoggers, { - native: { - filePath: "/tmp/events.log", - write: (event) => - Effect.sync(() => { - writes.push(event); - }), - close: () => Effect.void, - }, - canonical: undefined, - }), - ), - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), -); - const FORK_SESSION_ID = "11111111-1111-4111-8111-111111111111"; const writeClaudeSession = Effect.fnUntraced(function* (configDir: string) { @@ -163,27 +95,6 @@ it.effect("forks a session stored under the instance's own CLAUDE_CONFIG_DIR", ( }).pipe(Effect.provide(forkRunnerLayer)), ).pipe(Effect.provide(NodeServices.layer)), ); - -it.effect("forks in-process when the instance shares the server's config dir", () => - Effect.scoped( - Effect.gen(function* () { - const inProcessFork = vi - .spyOn(ClaudeSdk, "forkSession") - .mockResolvedValue({ sessionId: "forked-in-process" }); - yield* Effect.addFinalizer(() => Effect.sync(() => inProcessFork.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const forked = yield* runner.forkSession({ - sessionId: FORK_SESSION_ID, - options: { dir: "/repo" }, - environment: { ...process.env }, - threadId: ThreadId.make("thread-fork-shared-config-dir"), - providerSessionId: ProviderSessionId.make("session-fork-shared-config-dir"), - }); - - assert.equal(forked.sessionId, "forked-in-process"); - }).pipe(Effect.provide(forkRunnerLayer)), - ).pipe(Effect.provide(NodeServices.layer)), -); const realQueryRunner = layerQueryRunner.pipe( Layer.provide( Layer.succeed(ProviderEventLoggers, { @@ -194,52 +105,6 @@ const realQueryRunner = layerQueryRunner.pipe( Layer.provide(NodeServices.layer), ); -it.effect.each(["readable", "unreadable"] as const)( - "forks with a %s transcript, retaining the inherited head when available", - (transcript) => - Effect.gen(function* () { - let forkCompleted = false; - const fork = vi.spyOn(ClaudeSdk, "forkSession").mockImplementation(async () => { - forkCompleted = true; - return { sessionId: "forked-session" }; - }); - const read = vi.spyOn(ClaudeSdk, "getSessionMessages").mockImplementation(async () => { - assert.isTrue(forkCompleted); - if (transcript === "unreadable") throw new Error("transcript unreadable"); - return ["remapped-user", "remapped-assistant"].map((uuid) => ({ - type: "assistant" as const, - uuid, - session_id: "forked-session", - message: { role: "assistant", content: uuid }, - parent_tool_use_id: null, - parent_agent_id: null, - })); - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - fork.mockRestore(); - read.mockRestore(); - }), - ); - const runner = yield* ClaudeAgentSdkQueryRunner; - const forked = yield* runner.forkSession({ - sessionId: "source-session", - options: { dir: "/workspace", upToMessageId: "source-assistant" }, - environment: process.env, - threadId: ThreadId.make("thread-fork-head"), - providerSessionId: ProviderSessionId.make("session-fork-head"), - }); - assert.deepEqual(forked, { - sessionId: "forked-session", - ...(transcript === "readable" ? { headMessageId: "remapped-assistant" } : {}), - }); - assert.deepEqual(fork.mock.calls, [ - ["source-session", { dir: "/workspace", upToMessageId: "source-assistant" }], - ]); - assert.deepEqual(read.mock.calls, [["forked-session", { dir: "/workspace" }]]); - }).pipe(Effect.provide(realQueryRunner)), -); - function fakeClaudeCli(script: string): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-")); const cli = NodePath.join(dir, "claude"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index bca0e4570549..2a06d25c5030 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -105,22 +105,4 @@ describe("tailClaudeTaskOutput", () => { }).pipe(Effect.provide(NodeServices.layer)), ), ); - - it.live("ends when stopped even if Claude Code never wrote the file", () => - Effect.scoped( - Effect.gen(function* () { - const { root } = yield* makeTaskFile; - const stop = yield* Deferred.make(); - const fiber = yield* tailClaudeTaskOutput({ - root, - sessionId: SESSION_ID, - taskId: TASK_ID, - stop, - onChunk: () => Effect.die("no file, no output"), - }).pipe(Effect.forkScoped); - yield* Deferred.succeed(stop, undefined); - yield* Fiber.join(fiber); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d8d5c59c0dd3..7016549b7468 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; import { makeProviderTextDeltaCoalescer, @@ -31,10 +29,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexError from "effect-codex-app-server/errors"; import * as CodexReplay from "effect-codex-app-server/replay"; import type * as CodexSchema from "effect-codex-app-server/schema"; import * as DateTime from "effect/DateTime"; @@ -47,8 +42,7 @@ import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp" import { resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -57,7 +51,6 @@ import { TestClock } from "effect/testing"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import packageJson from "../../../package.json" with { type: "json" }; -import * as ServerConfig from "../../config.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -68,100 +61,14 @@ import * as ProviderReplayHarness from "../testkit/ProviderReplayHarness.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as CodexAdapterV2 from "./CodexAdapterV2.ts"; -import { mapCodexModelCapabilities } from "../../provider/CodexProvider.ts"; import { makeReplayServerConfig, withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts"; import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts"; -const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const replayTranscriptJson = Schema.fromJsonString(CodexReplay.CodexAppServerReplayTranscript); const encodeReplayTranscriptJson = Schema.encodeEffect(replayTranscriptJson); const decodeReplayTranscriptJson = Schema.decodeUnknownEffect(replayTranscriptJson); const encodeStringJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); -describe("Codex context usage compatibility", () => { - const previous: ModelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-6-astra", - }; - it("retains measured usage for reasoning-only changes in either direction", () => { - const low: ModelSelection = { ...previous, options: [{ id: "reasoningEffort", value: "low" }] }; - assert.isTrue(CodexAdapterV2.canReuseCodexContextUsage(previous, low)); - assert.isTrue(CodexAdapterV2.canReuseCodexContextUsage(low, previous)); - assert.isTrue( - CodexAdapterV2.canReuseCodexContextUsage(low, { - ...low, - options: [{ id: "reasoningEffort", value: "high" }], - }), - ); - }); - it("invalidates usage for model, instance, context-window and unknown option changes", () => { - for (const next of [ - { ...previous, model: "other-model" }, - { ...previous, instanceId: ProviderInstanceId.make("other-codex") }, - { ...previous, options: [{ id: "contextWindow", value: "32k" }] }, - { ...previous, options: [{ id: "customOption", value: "value" }] }, - ]) - assert.isFalse(CodexAdapterV2.canReuseCodexContextUsage(previous, next)); - }); -}); - -describe("CodexAdapterV2 file change approvals", () => { - it("uses nonblank reasons before sorted file operations and renamed paths", () => { - const fileChanges = { - "/tmp/removed.md": { type: "delete" as const, content: "gone" }, - "/tmp/added.ts": { type: "add" as const, content: "export {};" }, - "/tmp/moved.ts": { - type: "update" as const, - unified_diff: "@@", - move_path: "/tmp/renamed.ts", - }, - }; - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ - reason: " Update configuration. ", - fileChanges, - }), - "Update configuration.", - ); - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ reason: " ", fileChanges }), - "add /tmp/added.ts\nupdate /tmp/moved.ts -> /tmp/renamed.ts\ndelete /tmp/removed.md", - ); - }); - - it("falls back to a nonblank grant root and omits empty details", () => { - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ reason: " ", grantRoot: " /workspace " }), - "/workspace", - ); - assert.equal( - CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges: {}, grantRoot: "/workspace" }), - "/workspace", - ); - assert.isUndefined( - CodexAdapterV2.codexFileChangeApprovalPrompt({ - reason: " ", - grantRoot: " ", - fileChanges: {}, - }), - ); - }); - - it("bounds large patch descriptions without losing the remaining count", () => { - const fileChanges = Object.fromEntries( - Array.from({ length: 25 }, (_, index) => [ - `/tmp/file-${String(index).padStart(2, "0")}.ts`, - { type: "add" as const, content: "" }, - ]), - ); - const detail = CodexAdapterV2.codexFileChangeApprovalPrompt({ fileChanges }); - assert.equal(detail?.split("\n").length, 21); - assert.isTrue(detail?.startsWith("add /tmp/file-00.ts") ?? false); - assert.isTrue(detail?.endsWith("+5 more") ?? false); - assert.notInclude(detail, "file-20.ts"); - }); -}); - describe("CodexAdapterV2 context usage", () => { it("uses the current context rather than cumulative processed tokens", () => { const usage = CodexAdapterV2.codexProviderTurnTokenUsage( @@ -198,64 +105,6 @@ describe("CodexAdapterV2 context usage", () => { }); describe("CodexAdapterV2 assistant message streaming", () => { - it.effect("makes accumulated assistant text visible after the bounded flush interval", () => - Effect.gen(function* () { - const updates = yield* Ref.make< - ReadonlyArray<{ - readonly turnId: string; - readonly itemId: string; - readonly text: string; - readonly completed: boolean; - }> - >([]); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), - }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "partial" }); - assert.deepEqual(yield* Ref.get(updates), []); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.deepEqual(yield* Ref.get(updates), [ - { - turnId: "turn-1", - itemId: "message-1", - text: "partial", - completed: false, - }, - ]); - }), - ); - - it.effect("coalesces multiple token deltas into one assistant update per interval", () => - Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), - }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "one" }); - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " two" }); - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: " three" }); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.deepEqual(yield* Ref.get(updates), [ - { - turnId: "turn-1", - itemId: "message-1", - text: "one two three", - completed: false, - }, - ]); - }), - ); - it.effect("flushes buffered text synchronously before item and turn completion", () => Effect.gen(function* () { const updates = yield* Ref.make>([]); @@ -283,119 +132,6 @@ describe("CodexAdapterV2 assistant message streaming", () => { assert.equal((yield* Ref.get(updates)).length, 2); }), ); - - it.effect("retains buffered text until completion updates are emitted", () => - Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const failNext = yield* Ref.make(true); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => - Ref.getAndSet(failNext, false).pipe( - Effect.flatMap((shouldFail) => - shouldFail - ? Effect.die("projection unavailable") - : Ref.update(updates, (current) => [...current, update]), - ), - ), - }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "turn final" }); - const failedFlush = yield* coalescer.flushTurn("turn-1").pipe(Effect.exit); - assert.equal(failedFlush._tag, "Failure"); - yield* coalescer.flushTurn("turn-1"); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "item final" }); - yield* Ref.set(failNext, true); - const failedComplete = yield* coalescer - .complete({ turnId: "turn-1", itemId: "message-2" }) - .pipe(Effect.exit); - assert.equal(failedComplete._tag, "Failure"); - const completedText = yield* coalescer.complete({ - turnId: "turn-1", - itemId: "message-2", - }); - - assert.equal(completedText, "item final"); - assert.deepEqual(yield* Ref.get(updates), [ - { turnId: "turn-1", itemId: "message-1", text: "turn final", completed: true }, - { turnId: "turn-1", itemId: "message-2", text: "item final", completed: true }, - ]); - }), - ); - - it.effect("can discard an empty completion without emitting an assistant update", () => - Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), - }); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "" }); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - const completedText = yield* coalescer.complete({ - turnId: "turn-1", - itemId: "message-1", - finalText: "", - emitEmpty: false, - }); - - assert.equal(completedText, ""); - assert.deepEqual(yield* Ref.get(updates), []); - - yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "buffered" }); - assert.equal( - yield* coalescer.complete({ - turnId: "turn-1", - itemId: "message-2", - emitEmpty: false, - }), - "buffered", - ); - assert.deepEqual(yield* Ref.get(updates), [ - { - turnId: "turn-1", - itemId: "message-2", - text: "buffered", - completed: true, - }, - ]); - }), - ); - - it.effect("treats explicit empty final text as authoritative over buffered deltas", () => - Effect.gen(function* () { - const updates = yield* Ref.make>([]); - const coalescer = yield* makeProviderTextDeltaCoalescer({ - flushIntervalMs: 50, - emit: (update) => Ref.update(updates, (current) => [...current, update]), - }); - - yield* coalescer.append({ - turnId: "turn-1", - itemId: "message-1", - delta: "stale buffered text", - }); - const completedText = yield* coalescer.complete({ - turnId: "turn-1", - itemId: "message-1", - finalText: "", - }); - - assert.equal(completedText, ""); - assert.deepEqual(yield* Ref.get(updates), [ - { - turnId: "turn-1", - itemId: "message-1", - text: "", - completed: true, - }, - ]); - }), - ); }); describe("CodexAdapterV2 runtime policy", () => { @@ -438,31 +174,6 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("preserves explicit Codex turn policy overrides", () => - Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-override", - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: null, - approvalPolicy: "on-request", - sandboxPolicy: { - type: "readOnly", - }, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - }); - - assert.equal(params.approvalPolicy, "on-request"); - assert.equal(params.sandboxPolicy?.type, "readOnly"); - }), - ); - it.effect("sends MCP app model context as untrusted Codex context", () => Effect.gen(function* () { const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; @@ -493,5752 +204,1275 @@ describe("CodexAdapterV2 runtime policy", () => { assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); }), ); +}); - it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => +describe("CodexAdapterV2 native protocol logging", () => { + it.effect("logs decoded app-server frames once with credentials redacted", () => Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-orchestration-instructions", - codexInput: [{ type: "text", text: "delegate this task" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: null, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - hasT3Mcp: true, + const writes: Array<{ + readonly event: unknown; + readonly threadId: ThreadId | null; + }> = []; + const logger: ProviderEventLoggers.EventNdjsonLogger = { + filePath: "/tmp/events.log", + write: (event, threadId) => + Effect.sync(() => { + writes.push({ event, threadId }); + }), + close: () => Effect.void, + }; + const threadId = ThreadId.make("thread-1"); + const providerSessionId = ProviderSessionId.make("provider-session-1"); + const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ + nativeEventLogger: logger, + threadId, + providerSessionId, }); - assert.equal(params.collaborationMode?.mode, "default"); - assert.include( - params.additionalContext?.t3_code_orchestration_01?.value ?? "", - "Use `delegate_task`", - ); - assert.include( - params.additionalContext?.t3_code_orchestration_02?.value ?? "", - "structured object, never as JSON text", - ); - }), - ); + assert.notEqual(protocolLogger, undefined); + if (protocolLogger === undefined) { + return; + } - it.effect("omits default-mode collaboration settings without the T3 MCP server", () => - Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-default-without-t3-mcp", - codexInput: [{ type: "text", text: "implement this task" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: null, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: { + method: "thread/event", + params: { + id: "evt-1", + http_headers: { Authorization: "Bearer secret-codex-token" }, + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + }, }, - hasT3Mcp: false, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: + '{"method":"thread/event","params":{"http_headers":{"Authorization":"Bearer secret-codex-token"}}}\n', }); - assert.isUndefined(params.collaborationMode); - }), - ); - - it.effect("adds T3 plan-mode developer instructions when the T3 MCP server is attached", () => - Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-plan-with-t3-mcp", - codexInput: [{ type: "text", text: "plan this task" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "plan", - cwd: null, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", + assert.equal(writes.length, 1); + assert.equal(writes[0]?.threadId, threadId); + assert.deepEqual(writes[0]?.event, { + provider: "codex", + protocol: "codex.app-server", + kind: "protocol", + providerSessionId, + event: { + direction: "incoming", + stage: "decoded", + payload: { + method: "thread/event", + params: { + id: "evt-1", + http_headers: { Authorization: "[REDACTED]" }, + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + }, + }, }, - hasT3Mcp: true, }); - - assert.equal(params.collaborationMode?.mode, "plan"); - assert.include( - params.collaborationMode?.settings.developer_instructions ?? "", - "request_user_input", - ); - assert.include(params.additionalContext?.t3_code_tools?.value ?? "", "preview_status"); }), ); +}); - it.effect("keeps Codex in plan mode without referencing unavailable T3 MCP tools", () => +describe("CodexAdapterV2 rollback mapping", () => { + it.effect("derives native rollback count from durable provider turns", () => Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-plan-without-t3-mcp", - codexInput: [{ type: "text", text: "plan this task" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "plan", - cwd: null, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", + const now = yield* DateTime.now; + const providerThreadId = ProviderThreadId.make("provider-thread-codex-rollback"); + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: ProviderSessionId.make("provider-session-codex-rollback"), + appThreadId: ThreadId.make("thread-codex-rollback"), + ownerNodeId: null, + nativeThreadRef: { + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeId: "native-thread-codex-rollback", + strength: "strong", }, - hasT3Mcp: false, - }); - - assert.equal(params.collaborationMode?.mode, "plan"); - assert.notProperty(params.collaborationMode?.settings, "developer_instructions"); - assert.notProperty(params.collaborationMode?.settings, "reasoning_effort"); - assert.isUndefined(params.effort); - }), - ); - - it.effect.each(["default", "plan"] as const)( - "compiles per-turn Codex model options and cwd from their owning inputs in %s mode", - (interactionMode) => - Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-model-options", - hasT3Mcp: true, - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode, - cwd: "/workspace/model-options", - reasoningEffort: "low", - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - options: [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "serviceTier", value: "priority" }, - ], - }, - }); - - assert.equal(params.model, "gpt-5.4"); - assert.equal(params.effort, "xhigh"); - assert.equal(params.serviceTier, "priority"); - assert.equal(params.cwd, "/workspace/model-options"); - assert.equal(params.collaborationMode?.settings.model, "gpt-5.4"); - assert.equal(params.collaborationMode?.mode, interactionMode); - assert.equal(params.collaborationMode?.settings.reasoning_effort, "xhigh"); - assert.include( - params.additionalContext?.t3_code_runtime?.value ?? "", - "as gpt-5.4 with xhigh reasoning effort", - ); - - const managed = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-model-options", - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace/model-options", - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - options: [{ id: "serviceTier", value: "priority" }], - }, - omitServiceTier: true, - }); - assert.equal(managed.serviceTier, undefined); - - for (const tier of [undefined, "default"]) { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-model-options", - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - options: tier === undefined ? [] : [{ id: "serviceTier", value: tier }], - }, - }); - if (tier === undefined) assert.notProperty(params, "serviceTier"); - else assert.equal(params.serviceTier, "default"); - } - }), - ); - - it.effect("uses Astra's advertised effort default while preserving explicit selections", () => - Effect.gen(function* () { - const model = "gpt-6-astra"; - const capabilities = mapCodexModelCapabilities({ - id: model, - model, - displayName: model, - description: "Test model", - hidden: false, - isDefault: true, - defaultReasoningEffort: "low", - supportedReasoningEfforts: ["low", "medium", "high"].map((reasoningEffort) => ({ - reasoningEffort, - description: reasoningEffort, - })), - additionalSpeedTiers: [], + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 3, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const providerTurn = ( + id: string, + ordinal: number, + status: OrchestrationV2ProviderTurn["status"], + ): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(id), + providerThreadId, + nodeId: NodeId.make(`node-${id}`), + runAttemptId: RunAttemptId.make(`run-attempt-${id}`), + nativeTurnRef: { + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeId: `native-${id}`, + strength: "strong", + }, + ordinal, + status, + startedAt: now, + completedAt: status === "running" || status === "pending" ? null : now, }); - const advertised = capabilities.optionDescriptors?.find( - (option) => option.id === "reasoningEffort", - )?.currentValue; - assert.equal(advertised, "medium"); - for (const selected of [undefined, "high"] as const) { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-astra-effort", - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model, - ...(selected ? { options: [{ id: "reasoningEffort", value: selected }] } : {}), - }, - hasT3Mcp: true, - }); - const expected = selected ?? advertised; - assert.equal(params.effort, expected); - assert.equal(params.collaborationMode?.settings.reasoning_effort, expected); - assert.include( - params.additionalContext?.t3_code_runtime?.value ?? "", - `${expected} reasoning effort`, - ); - } - }), - ); + const firstTurn = providerTurn("provider-turn-first", 1, "completed"); + const secondTurn = providerTurn("provider-turn-second", 2, "completed"); + const runningTurn = providerTurn("provider-turn-running", 3, "running"); + const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted"); + const goalCommandTurn = { + ...providerTurn("provider-turn-goal-command", 5, "completed"), + nativeTurnRef: null, + }; - it.effect.each(["default", "plan"] as const)( - "omits unset effort from T3 %s collaboration settings and runtime context", - (interactionMode) => - Effect.gen(function* () { - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId: "native-unset-effort", - codexInput: [{ type: "text", text: "test" }], - runtimePolicy: { - runtimeMode: "full-access", - interactionMode, - cwd: null, - }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - hasT3Mcp: true, - }); + const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-first"), + appRunOrdinal: 1, + providerTurn: firstTurn, + }, + providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn], + }); - assert.isUndefined(params.effort); - assert.equal(params.collaborationMode?.mode, interactionMode); - assert.equal(params.collaborationMode?.settings.model, "gpt-5.4"); - assert.notProperty(params.collaborationMode?.settings, "reasoning_effort"); - const runtimeContext = params.additionalContext?.t3_code_runtime?.value ?? ""; - assert.include(runtimeContext, "as gpt-5.4"); - assert.notInclude(runtimeContext, "reasoning effort"); - }), + assert.equal(numTurns, 2); + }), ); }); -describe("CodexAdapterV2 process spawning", () => { - it.effect("resolves Windows command shims through the shared spawn policy", () => - Effect.gen(function* () { - const command = yield* CodexAdapterV2.makeCodexAppServerSpawnCommand({ - command: "codex", - args: ["app-server", "argument with spaces"], - cwd: "C:\\workspace", - env: { CUSTOM: "1" }, - extendEnv: true, - }); - - assert.isTrue(ChildProcess.isStandardCommand(command)); - if (!ChildProcess.isStandardCommand(command)) { - return; - } - assert.equal( - command.command, - '^"C:\\npm\\codex.cmd^" ^"app-server^" ^"argument^ with^ spaces^"', - ); - assert.deepEqual(command.args, []); - assert.equal(command.options.shell, true); - assert.equal(command.options.cwd, "C:\\workspace"); - assert.deepEqual(command.options.env, { CUSTOM: "1" }); - assert.equal(command.options.extendEnv, true); - }).pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Environment, { - PATH: "C:\\Windows\\System32", - HOST_ONLY: "1", - }), - Effect.provideService(SpawnExecutableResolution, (_command, _platform, environment) => { - assert.equal(environment.HOST_ONLY, "1"); - assert.equal(environment.CUSTOM, "1"); - return "C:\\npm\\codex.cmd"; - }), - ), - ); +describe("CodexAdapterV2 fork boundary", () => { + const providerThreadId = ProviderThreadId.make("provider-thread-codex-fork-boundary"); + const makeProviderThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({ + id: providerThreadId, + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: ProviderSessionId.make("provider-session-codex-fork-boundary"), + appThreadId: ThreadId.make("thread-codex-fork-boundary"), + ownerNodeId: null, + nativeThreadRef: { + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeId: "native-thread-codex-fork-boundary", + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 2, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + const makeProviderTurn = ( + id: string, + ordinal: number, + nativeId: string | null, + now: DateTime.Utc, + ): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(id), + providerThreadId, + nodeId: NodeId.make(`node-${id}`), + runAttemptId: RunAttemptId.make(`run-attempt-${id}`), + nativeTurnRef: + nativeId === null + ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } + : { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: now, + completedAt: now, + }); - it.effect("uses direct execution for native executables", () => + it.effect("resolves the selected provider turn to an inclusive native fork boundary", () => Effect.gen(function* () { - const command = yield* CodexAdapterV2.makeCodexAppServerSpawnCommand({ - command: "codex.exe", - args: ["app-server"], - }); - - assert.isTrue(ChildProcess.isStandardCommand(command)); - if (!ChildProcess.isStandardCommand(command)) { - return; - } - assert.equal(command.command, "C:\\bin\\codex.exe"); - assert.deepEqual(command.args, ["app-server"]); - assert.equal(command.options.shell, false); - }).pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(SpawnExecutableResolution, () => "C:\\bin\\codex.exe"), - ), - ); + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - it.effect("launches the app-server with the configured launch arguments", () => - Effect.gen(function* () { - const spawnedArgs: Array> = []; - const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedArgs.push(command.args); - return Effect.fail( - PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), - ); + const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), }); - const factory = yield* CodexAdapterV2.CodexAppServerClientFactory.pipe( - Effect.provide(CodexAdapterV2.layerAppServerClientFactory), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ); - const open = (environment: NodeJS.ProcessEnv) => - factory - .open({ - instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - threadId: ThreadId.make("thread-launch-args"), - providerSessionId: ProviderSessionId.make("provider-session-launch-args"), - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - }), - settings: { - ...DEFAULT_CODEX_SETTINGS, - launchArgs: " --strict-config -c model_reasoning_summary=detailed ", - }, - environment, - }) - .pipe(Effect.scoped, Effect.exit); - - yield* open({}); - yield* open({ T3CODE_CODEX_LAUNCH_ARGS: " --enable env-feature " }); - - assert.deepEqual(spawnedArgs, [ - ["app-server", "--strict-config", "-c", "model_reasoning_summary=detailed"], - ["app-server", "--enable", "env-feature"], - ]); - }).pipe(Effect.provideService(HostProcess.Platform, "linux")), - ); - it.effect("expands ~ in the configured binary path before spawning", () => - Effect.gen(function* () { - const spawnedCommands: Array = []; - const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) spawnedCommands.push(command.command); - return Effect.fail( - PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", method: "spawn" }), - ); + assert.deepEqual(boundary, { + lastTurnId: "native-turn-first", + rollbackTurnCount: 0, }); - const path = yield* Path.Path; - const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ - instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - displayName: undefined, - environment: [], - enabled: true, - config: { ...DEFAULT_CODEX_SETTINGS, binaryPath: "~/bin/codex" }, - }).pipe( - Effect.provide( - Layer.mergeAll( - CodexAdapterV2.layerAppServerClientFactory, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-binary-home-" }), - ), - ), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ); - - yield* adapter - .openSession({ - threadId: ThreadId.make("thread-binary-home"), - providerSessionId: ProviderSessionId.make("provider-session-binary-home"), - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }) - .pipe(Effect.scoped, Effect.exit); - - assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - Effect.provideService(HostProcess.Platform, "linux"), - ), + }), ); -}); -describe("CodexAdapterV2 dynamic tool projection", () => { - it.effect("keeps a failed Sky MCP call in Computer Use when its error has no metadata", () => + it.effect("keeps the rollback-count fallback when the boundary turn lacks a native id", () => Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "failed-sky-js", - server: "node_repl", - tool: "js", - status: "failed", - arguments: { - title: "Restrict the breakpoint to Git actions", - code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});", - }, - result: { content: [{ type: "text", text: "Illegal Unicode escape sequence" }] }, - }); - assert.equal(projection.title, "Restrict the breakpoint to Git actions"); - assert.equal(projection.toolSurface, "computer"); - assert.deepEqual(projection.toolSource, { - key: "computer-use", - name: "Computer Use", - kind: "computer", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect.each(["inProgress", "completed", "failed"] as const)( - "preserves a bare js action's title and computer surface when %s without result metadata", - (status) => - Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "dynamicToolCall", - id: "bare-js", - tool: "js", - status, - arguments: { code: "await sky.get_app_state(app);", title: "Read the desktop window" }, - contentItems: [], - success: status !== "failed", - }); - assert.equal(projection.title, "Read the desktop window"); - assert.equal(projection.toolSurface, "computer"); - }).pipe(Effect.provide(NodeServices.layer)), - ); + const now = yield* DateTime.now; + const firstTurn = makeProviderTurn("provider-turn-first", 1, null, now); + const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - it.effect.each(["cua_repl", "node_repl"])( - "uses the %s call title while leaving other MCP titles as tool arguments", - (server) => - Effect.gen(function* () { - const call = { - type: "mcpToolCall" as const, - id: "inspect", - server, - tool: "js", - status: "completed" as const, - arguments: { - code: "await game.getAXStateAndScreenshot();", - title: "Inspect Saga music screen", - }, - result: { content: [] }, - }; - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title, - "Inspect Saga music screen", - ); - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem({ - ...call, - arguments: { title: " " }, - })).title, - server === "cua_repl" ? "Computer use" : "Run JavaScript", - ); - assert.equal( - (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title, - "js", - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); + const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ + sourceProviderThread: makeProviderThread(now), + sourceProviderTurns: [firstTurn, secondTurn], + providerTurnId: firstTurn.id, + targetThreadId: ThreadId.make("thread-codex-fork-target"), + }); - it.effect.each(["inProgress", "completed", "failed"] as const)( - "presents ordinary MCP calls when %s", - (status) => - Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "weather-call", - server: "weather", - tool: "get_weather", - status, - arguments: { city: "Berlin" }, - }); - assert.equal(projection.title, "get weather"); - assert.deepEqual(projection.toolSource, { - key: "mcp:weather", - name: "weather", - kind: "integration", - }); - assert.deepEqual(projection.input, { city: "Berlin" }); - }).pipe(Effect.provide(NodeServices.layer)), + assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); + }), ); +}); - it.effect("uses Codex connector names without reading a display title from arguments", () => - Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "connector-call", - server: "_apps", - tool: "connector_get_weather", - status: "completed", - arguments: { title: "Argument, not display metadata" }, - appContext: { - connectorId: "weather-app", - appName: "Weather", - actionName: "Check weather", - }, - result: { - content: [], - _meta: { source: { logoUrl: "https://example.com/weather.png" } }, - }, - }); - assert.equal(projection.title, "Check weather"); - assert.equal(projection.toolSource?.name, "Weather"); - assert.deepEqual(projection.toolIcon, { - _tag: "themed-logo", - logoUrl: "https://example.com/weather.png", - }); - assert.deepEqual(projection.toolSource?.icon, projection.toolIcon); - }).pipe(Effect.provide(NodeServices.layer)), - ); +const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); +const CODEX_TEST_MODEL_SELECTION = { + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + model: "gpt-5.4", +} satisfies ModelSelection; +const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", +}); - it.effect("preserves native browser and app icons alongside MCP tool output", () => - Effect.gen(function* () { - const browser = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "browser", - server: "browser", - tool: "open", - status: "completed", - arguments: {}, - result: { - content: [], - _meta: { - "codex/toolSurface": { - kind: "browserUse", - browserFamily: "Chrome", - screenshot: { - pageUrl: "https://example.com/docs", - faviconUrl: "https://example.com/icon.png", - }, +function makeCodexTestAppThread(input: { + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + readonly now: DateTime.Utc; +}): OrchestrationV2AppThread { + return { + createdBy: "user", + creationSource: "web", + id: input.threadId, + projectId: ProjectId.make(`project-${input.threadId}`), + title: "Codex continuation test", + providerInstanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: input.providerThread.id, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: input.threadId, + }, + forkedFrom: null, + createdAt: input.now, + updatedAt: input.now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; +} + +function makeCodexTestTurnInput(input: { + readonly threadId: ThreadId; + readonly providerThread: OrchestrationV2ProviderThread; + readonly now: DateTime.Utc; + readonly attemptId: RunAttemptId; + readonly text: string; +}): ProviderAdapter.ProviderAdapterV2TurnInput { + return { + appThread: makeCodexTestAppThread(input), + threadId: input.threadId, + runId: RunId.make(`run-${input.attemptId}`), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: input.attemptId, + rootNodeId: NodeId.make(`node-${input.attemptId}`), + providerThread: input.providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`message-${input.attemptId}`), + text: input.text, + attachments: [], + }, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }; +} + +function makeCodexReplayTurn(input: { + readonly id: string; + readonly status: "inProgress" | "completed" | "interrupted" | "failed"; +}): Record { + const terminal = + input.status === "completed" || input.status === "interrupted" || input.status === "failed"; + return { + id: input.id, + items: [], + itemsView: "notLoaded", + status: input.status, + error: null, + startedAt: 1782622440, + completedAt: terminal ? 1782622450 : null, + durationMs: null, + }; +} + +function codexReplayPreamble(input: { + readonly nativeThreadId: string; + readonly nativeTurnId: string; + readonly prompt: string; + readonly sentPrompt?: string; +}): Array { + return [ + { + type: "expect_outbound", + label: "initialize", + frame: { + id: 1, + method: "initialize", + params: { + clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: ["turn/diff/updated"], + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, }, }, }, - }); - assert.equal(browser.toolSurface, "browser"); - assert.deepEqual(browser.toolIcon, { - _tag: "website", - pageUrl: "https://example.com/docs", - faviconUrl: "https://example.com/icon.png", - }); - assert.equal(browser.toolSource?.name, "Chrome"); - const app = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "app", - server: "computer", - tool: "click", - status: "completed", - arguments: {}, + }, + }, + { + type: "emit_inbound", + label: "initialize", + frame: { + id: 1, result: { - content: [], - _meta: { - "codex/toolSurface": { - kind: "computerUse", - app: { kind: "appId", appId: "com.apple.finder" }, - }, - }, - }, - }); - assert.deepEqual(app.toolIcon, { - _tag: "native-app", - app: { _tag: "app-id", appId: "com.apple.finder" }, - }); - assert.equal(app.toolSource?.name, "Finder"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves MCP arguments and prefers structured output", () => - Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "mcpToolCall", - id: "call-create-threads", - server: "t3-code", - tool: "create_threads", - status: "completed", - arguments: { - threads: [{ title: "Fixture child", prompt: "fixture child prompt" }], + userAgent: "T3 Code/0.156.1", + codexHome: "/tmp/codex-home", + platformFamily: "unix", + platformOs: "macos", }, + }, + }, + { type: "expect_outbound", label: "initialized", frame: { method: "initialized" } }, + { + type: "expect_outbound", + label: "thread/start", + frame: { + id: 2, + method: "thread/start", + params: { config: CodexAdapterV2.CODEX_THREAD_CONFIG }, + }, + }, + { + type: "emit_inbound", + label: "thread/start", + frame: { + id: 2, result: { - content: [{ type: "text", text: '{"threads":[{"threadId":"thread:mcp:fixture:0"}]}' }], - structuredContent: { - threads: [{ threadId: "thread:mcp:fixture:0" }], + thread: { + id: input.nativeThreadId, + sessionId: input.nativeThreadId, + forkedFromId: null, + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 1782622440, + updatedAt: 1782622440, + status: { type: "idle" }, + path: `/tmp/${input.nativeThreadId}.jsonl`, + cwd: "/workspace", + cliVersion: "0.144.0", + source: "vscode", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], }, + model: "gpt-5.4", + modelProvider: "openai", + serviceTier: null, + cwd: "/workspace", + instructionSources: [], + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, + reasoningEffort: "medium", }, - }); - - assert.deepEqual(projection, { - toolName: "t3-code.create_threads", - input: { - threads: [{ title: "Fixture child", prompt: "fixture child prompt" }], + }, + }, + { + type: "expect_outbound", + label: "turn/start", + frame: { + id: 3, + method: "turn/start", + params: { + threadId: input.nativeThreadId, + input: [{ type: "text", text: input.sentPrompt ?? input.prompt }], + cwd: "/workspace", + model: "gpt-5.4", + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + summary: "detailed", }, - output: { - threads: [{ threadId: "thread:mcp:fixture:0" }], + }, + }, + { + type: "emit_inbound", + label: "turn/start", + frame: { + id: 3, + result: { turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }) }, + }, + }, + { + type: "emit_inbound", + label: "turn/started", + frame: { + method: "turn/started", + params: { + threadId: input.nativeThreadId, + turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }), }, - status: "completed", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); + }, + }, + ]; +} - it.effect("preserves namespaced dynamic tool output", () => - Effect.gen(function* () { - const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({ - type: "dynamicToolCall", - id: "call-dynamic", - namespace: "workspace", - tool: "inspect", - status: "failed", - arguments: { path: "package.json" }, - contentItems: [{ type: "inputText", text: "inspection failed" }], - success: false, - }); +function makeCodexReplayTranscript(input: { + readonly scenario: string; + readonly entries: ReadonlyArray; +}): CodexReplay.CodexAppServerReplayTranscript { + return { + provider: "codex", + protocol: "codex.app-server", + version: "0.144.0", + scenario: input.scenario, + entries: input.entries, + }; +} - assert.deepEqual(projection, { - toolName: "workspace.inspect", - input: { path: "package.json" }, - output: [{ type: "inputText", text: "inspection failed" }], - status: "failed", - }); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); +function withReplayRequestId( + entry: CodexReplay.CodexAppServerReplayEntry, + id: number, +): CodexReplay.CodexAppServerReplayEntry { + return entry.type !== "runtime_exit" && Predicate.isObject(entry.frame) + ? { ...entry, frame: { ...entry.frame, id } } + : entry; +} -describe("CodexAdapterV2 native protocol logging", () => { - it.effect("logs decoded app-server frames once with credentials redacted", () => +describe("CodexAdapterV2 session initialize", () => { + const openReplaySession = ( + transcript: CodexReplay.CodexAppServerReplayTranscript, + beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], + ) => Effect.gen(function* () { - const writes: Array<{ - readonly event: unknown; - readonly threadId: ThreadId | null; - }> = []; - const logger: ProviderEventLoggers.EventNdjsonLogger = { - filePath: "/tmp/events.log", - write: (event, threadId) => - Effect.sync(() => { - writes.push({ event, threadId }); - }), - close: () => Effect.void, - }; - const threadId = ThreadId.make("thread-1"); - const providerSessionId = ProviderSessionId.make("provider-session-1"); - const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ - nativeEventLogger: logger, - threadId, - providerSessionId, - }); - - assert.notEqual(protocolLogger, undefined); - if (protocolLogger === undefined) { - return; - } - - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: { - method: "thread/event", - params: { - id: "evt-1", - http_headers: { Authorization: "Bearer secret-codex-token" }, - usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, - }, + const driver = yield* CodexReplay.makeReplayDriver( + transcript, + beforeEmitInbound === undefined ? {} : { beforeEmitInbound }, + ); + let initializeRequests = 0; + const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CODEX_SETTINGS, + environment: {}, + clientFactory: { + open: (openInput) => + Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( + Effect.flatMap((context) => + Effect.service(CodexClient.CodexAppServerClient).pipe(Effect.provide(context)), + ), + Effect.map( + (client) => + ({ + ...client, + request: (method, params) => + Effect.sync(() => { + if (method === "initialize") initializeRequests++; + }).pipe(Effect.andThen(client.request(method, params))), + }) satisfies CodexClient.CodexAppServerClient["Service"], + ), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + ), }, + crypto: yield* Crypto.Crypto, + fileSystem: yield* FileSystem.FileSystem, + idAllocator: yield* IdAllocator.IdAllocatorV2, + serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), }); - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - payload: - '{"method":"thread/event","params":{"http_headers":{"Authorization":"Bearer secret-codex-token"}}}\n', + const runtime = yield* adapter.openSession({ + threadId: ThreadId.make(`thread-${transcript.scenario}`), + providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); + return { + ensureThread: (threadId: string) => + runtime.ensureThread({ + threadId: ThreadId.make(threadId), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }), + initializeRequests: () => initializeRequests, + }; + }); - assert.equal(writes.length, 1); - assert.equal(writes[0]?.threadId, threadId); - assert.deepEqual(writes[0]?.event, { - provider: "codex", - protocol: "codex.app-server", - kind: "protocol", - providerSessionId, - event: { - direction: "incoming", - stage: "decoded", - payload: { - method: "thread/event", - params: { - id: "evt-1", - http_headers: { Authorization: "[REDACTED]" }, - usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, - }, - }, - }, - }); - }), - ); + const replayPreamble = (nativeThreadId: string) => + codexReplayPreamble({ nativeThreadId, nativeTurnId: "unused", prompt: "unused" }); - it.effect("filters streaming frames before redaction without losing decode failures", () => + it.effect("sends one initialize when two threads start on a fresh session at once", () => Effect.gen(function* () { - const writes: Array = []; - const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ - nativeEventLogger: { - filePath: "/tmp/events.log", - write: (event) => - Effect.sync(() => { - writes.push(event); - }), - close: () => Effect.void, - }, - threadId: ThreadId.make("thread-1"), - providerSessionId: ProviderSessionId.make("provider-session-1"), - }); - assert.exists(protocolLogger); - if (protocolLogger === undefined) return; + const initializeAwaitingResponse = yield* Deferred.make(); + const releaseInitialize = yield* Deferred.make(); + // The transcript allows exactly one handshake: a second `initialize` + // frame fails the replay, as Codex rejects it with "Already initialized". + const session = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "concurrent-initialize", + entries: [ + ...replayPreamble("concurrent-first").slice(0, 5), + ...replayPreamble("concurrent-second") + .slice(3, 5) + .map((entry) => withReplayRequestId(entry, 3)), + ], + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Deferred.await(releaseInitialize)), + ) + : Effect.void, + ); - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: { - method: "item/agentMessage/delta", - get params() { - throw new Error("delta must not be copied"); - }, - }, - }); - yield* protocolLogger({ - direction: "incoming", - stage: "raw", - get payload() { - throw new Error("raw frame must not be parsed"); - }, - }); - yield* protocolLogger({ - direction: "incoming", - stage: "decode_failed", - payload: { operation: "decode", method: "turn/completed", issueCount: 1 }, - }); + const first = yield* session + .ensureThread("thread-concurrent-first") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + const second = yield* session + .ensureThread("thread-concurrent-second") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(releaseInitialize, undefined); - assert.equal(writes.length, 1); - assert.nestedPropertyVal(writes[0], "event.stage", "decode_failed"); - assert.nestedPropertyVal(writes[0], "event.payload.method", "turn/completed"); - }), + const providerThreads = [yield* Fiber.join(first), yield* Fiber.join(second)]; + assert.equal(session.initializeRequests(), 1); + assert.sameMembers( + providerThreads.map((providerThread) => providerThread.nativeThreadRef?.nativeId), + ["concurrent-first", "concurrent-second"], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); - it.effect("retains redacted failures when large payloads are summarized", () => + // regression: #16260 one-shot initializers raced; a failed handshake must be retried + it.effect("retries initialize after a failed handshake", () => Effect.gen(function* () { - const writes: Array = []; - const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ - nativeEventLogger: { - filePath: "/tmp/events.log", - write: (event) => - Effect.sync(() => { - writes.push(event); - }), - close: () => Effect.void, + const preamble = replayPreamble("initialize-retry"); + const entries: Array = [ + ...preamble.slice(0, 1), + { + type: "emit_inbound", + label: "initialize", + frame: { id: 1, error: { code: -32603, message: "Codex is not ready." } }, }, - threadId: ThreadId.make("thread-1"), - providerSessionId: ProviderSessionId.make("provider-session-1"), - }); - assert.exists(protocolLogger); - if (protocolLogger === undefined) return; + ...preamble.slice(0, 2).map((entry) => withReplayRequestId(entry, 2)), + ...preamble.slice(2, 3), + ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), + ]; + const session = yield* openReplaySession( + makeCodexReplayTranscript({ scenario: "initialize-retry", entries }), + ); - yield* protocolLogger({ - direction: "incoming", - stage: "decoded", - payload: { - method: "error", - params: { - threadId: "native-thread", - turnId: "native-turn", - error: { - code: "unauthorized", - message: '{"message":"Unauthorized","Authorization":"Bearer secret-token"}', + const failure = yield* session.ensureThread("thread-initialize-retry").pipe(Effect.flip); + assert.equal(failure._tag, "ProviderAdapterEnsureThreadError"); + const providerThread = yield* session.ensureThread("thread-initialize-retry"); + assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-retry"); + assert.equal(session.initializeRequests(), 2); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("completes the handshake after a caller is interrupted mid-initialize", () => + Effect.gen(function* () { + const initializeAwaitingResponse = yield* Deferred.make(); + const releaseInitialize = yield* Deferred.make(); + const preamble = replayPreamble("initialize-interrupted"); + const session = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "initialize-interrupted", + entries: [ + ...preamble.slice(0, 2), + ...preamble.slice(0, 1).map((entry) => withReplayRequestId(entry, 2)), + { + type: "emit_inbound", + label: "initialize-rejected", + frame: { id: 2, error: { code: -32600, message: "Already initialized" } }, }, - history: "x".repeat(128 * 1_024), - }, - }, - }); + ...preamble.slice(2, 3), + ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), + ], + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Deferred.await(releaseInitialize)), + ) + : Effect.void, + ); - const serialized = encodeUnknownJson(writes); - assert.equal(writes.length, 1); - assert.isBelow(serialized.length, 2_048); - assert.notInclude(serialized, "secret-token"); - assert.include(serialized, "[REDACTED]"); - assert.nestedPropertyVal(writes[0], "event.payload.params.error.code", "unauthorized"); - assert.nestedPropertyVal(writes[0], "event.payload.params.turnId", "native-turn"); - }), + const interrupted = yield* session + .ensureThread("thread-initialize-interrupted") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + yield* Fiber.interrupt(interrupted); + yield* Deferred.succeed(releaseInitialize, undefined); + + const providerThread = yield* session.ensureThread("thread-initialize-interrupted"); + assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-interrupted"); + assert.equal(session.initializeRequests(), 2); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), ); }); -describe("CodexAdapterV2 rollback mapping", () => { - it.effect("derives native rollback count from durable provider turns", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const providerThreadId = ProviderThreadId.make("provider-thread-codex-rollback"); - const providerThread: OrchestrationV2ProviderThread = { - id: providerThreadId, - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerInstanceId: ProviderInstanceId.make("codex"), - providerSessionId: ProviderSessionId.make("provider-session-codex-rollback"), - appThreadId: ThreadId.make("thread-codex-rollback"), - ownerNodeId: null, - nativeThreadRef: { - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeId: "native-thread-codex-rollback", - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 3, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }; - const providerTurn = ( - id: string, - ordinal: number, - status: OrchestrationV2ProviderTurn["status"], - ): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(id), - providerThreadId, - nodeId: NodeId.make(`node-${id}`), - runAttemptId: RunAttemptId.make(`run-attempt-${id}`), - nativeTurnRef: { - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeId: `native-${id}`, - strength: "strong", - }, - ordinal, - status, - startedAt: now, - completedAt: status === "running" || status === "pending" ? null : now, - }); - const firstTurn = providerTurn("provider-turn-first", 1, "completed"); - const secondTurn = providerTurn("provider-turn-second", 2, "completed"); - const runningTurn = providerTurn("provider-turn-running", 3, "running"); - const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted"); - const goalCommandTurn = { - ...providerTurn("provider-turn-goal-command", 5, "completed"), - nativeTurnRef: null, - }; - - const numTurns = yield* CodexAdapterV2.resolveCodexRollbackTurnCount({ - providerThread, - target: { - type: "provider_turn", - checkpointId: CheckpointId.make("checkpoint-first"), - appRunOrdinal: 1, - providerTurn: firstTurn, - }, - providerThreadTurns: [goalCommandTurn, interruptedTurn, runningTurn, secondTurn, firstTurn], - }); - - assert.equal(numTurns, 2); - }), - ); -}); - -describe("CodexAdapterV2 fork boundary", () => { - const providerThreadId = ProviderThreadId.make("provider-thread-codex-fork-boundary"); - const makeProviderThread = (now: DateTime.Utc): OrchestrationV2ProviderThread => ({ - id: providerThreadId, - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerInstanceId: ProviderInstanceId.make("codex"), - providerSessionId: ProviderSessionId.make("provider-session-codex-fork-boundary"), - appThreadId: ThreadId.make("thread-codex-fork-boundary"), - ownerNodeId: null, - nativeThreadRef: { - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeId: "native-thread-codex-fork-boundary", - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }); - const makeProviderTurn = ( - id: string, - ordinal: number, - nativeId: string | null, - now: DateTime.Utc, - ): OrchestrationV2ProviderTurn => ({ - id: ProviderTurnId.make(id), - providerThreadId, - nodeId: NodeId.make(`node-${id}`), - runAttemptId: RunAttemptId.make(`run-attempt-${id}`), - nativeTurnRef: - nativeId === null - ? { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId: null, strength: "none" } - : { driver: CodexAdapterV2.CODEX_DRIVER_KIND, nativeId, strength: "strong" }, - ordinal, - status: "completed", - startedAt: now, - completedAt: now, +describe("CodexAdapterV2 post-settle continuation", () => { + // The harness notifies after every recorded event or continuation, so a wait + // re-checks exactly when the state it reads changes. Subscribing before the + // first check means a change that lands in between is not lost. + const makeChangeSignal = Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const until = (predicate: () => boolean): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + while (!predicate()) yield* PubSub.take(subscription); + }), + ); + return { notify: PubSub.publish(changes, undefined), until }; }); - it.effect("resolves the selected provider turn to an inclusive native fork boundary", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); - const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }); - - assert.deepEqual(boundary, { - lastTurnId: "native-turn-first", - rollbackTurnCount: 0, - }); - }), - ); - - it.effect("resolves the latest source turn to a native fork boundary without rollback", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); - const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: secondTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }); - - assert.deepEqual(boundary, { - lastTurnId: "native-turn-second", - rollbackTurnCount: 0, - }); - }), - ); - - it.effect("keeps the rollback-count fallback when the boundary turn lacks a native id", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn = makeProviderTurn("provider-turn-first", 1, null, now); - const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }); - - assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); - }), - ); - - it.effect("keeps the rollback-count fallback when the boundary turn has no native ref", () => + const makeCodexReplayHarness = ( + transcript: CodexReplay.CodexAppServerReplayTranscript, + onEvent: (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.Effect = () => + Effect.void, + onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, + readChildMetadata?: Parameters[2], + subscriptionLimitPolicy?: SubscriptionLimitPolicy.SubscriptionLimitPolicy, + ) => Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn: OrchestrationV2ProviderTurn = { - ...makeProviderTurn("provider-turn-first", 1, "native-turn-first", now), - nativeTurnRef: null, + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); + const changes = yield* makeChangeSignal; + const continuationRequests: Array = + []; + const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { + open: (openInput) => + Layer.build(CodexReplay.layerReplay(transcript)).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + Effect.flatMap((context) => + Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.map((client) => + withCodexReplayChildMetadata(client, transcript, readChildMetadata), + ), + Effect.map( + (client) => + ({ + ...client, + request: (method, params) => + onRequest(method, params).pipe( + Effect.andThen(client.request(method, params)), + ), + }) satisfies CodexClient.CodexAppServerClient["Service"], + ), + Effect.provide(context), + ), + ), + ), }; - const secondTurn = makeProviderTurn("provider-turn-second", 2, "native-turn-second", now); - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn, secondTurn], - providerTurnId: firstTurn.id, - targetThreadId: ThreadId.make("thread-codex-fork-target"), + const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CODEX_SETTINGS, + environment: {}, + clientFactory, + crypto: yield* Crypto.Crypto, + fileSystem, + idAllocator, + serverConfig, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }).pipe(Effect.andThen(changes.notify)), + }, + ...(subscriptionLimitPolicy === undefined ? {} : { subscriptionLimitPolicy }), }); - - assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 1 }); - }), - ); - - it.effect("forks at head without a boundary when no provider turn is selected", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - - const boundary = yield* CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - targetThreadId: ThreadId.make("thread-codex-fork-target"), + const threadId = ThreadId.make(`thread-${transcript.scenario}`); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }); - - assert.deepEqual(boundary, { lastTurnId: undefined, rollbackTurnCount: 0 }); - }), - ); - - it.effect("fails with a typed error when the selected source turn is missing", () => - Effect.gen(function* () { - const now = yield* DateTime.now; - const firstTurn = makeProviderTurn("provider-turn-first", 1, "native-turn-first", now); - - const error = yield* Effect.flip( - CodexAdapterV2.resolveCodexForkBoundary({ - sourceProviderThread: makeProviderThread(now), - sourceProviderTurns: [firstTurn], - providerTurnId: ProviderTurnId.make("provider-turn-missing"), - targetThreadId: ThreadId.make("thread-codex-fork-target"), - }), - ); - - assert.instanceOf(error, ProviderAdapter.ProviderAdapterForkThreadError); - assert.include(String(error.cause), "provider-turn-missing"); - }), - ); -}); - -describe("CodexAdapterV2 skill mentions", () => { - it("sends currency-sigil skill mentions as the $ mention Codex parses", () => { - const cases: ReadonlyArray = [ - ["€review do it", "$review do it"], - ["£ship", "$ship"], - ["please ¥review this diff", "please $review this diff"], - ["first line\n₹ship it", "first line\n$ship it"], - ["𑿝review then €2spec", "$review then $2spec"], - ["$review", "$review"], - ["costs €20", "costs €20"], - ["€5k", "€5k"], - ["budget €100M or €1e6", "budget €100M or €1e6"], - ["5€review", "5€review"], - ]; - for (const [text, expected] of cases) { - assert.equal(CodexAdapterV2.codexSkillMentionText(text), expected, text); - } - }); -}); - -describe("CodexAdapterV2 background command detail", () => { - it("summarizes command, exit code, and output tail", () => { - assert.equal( - CodexAdapterV2.codexBackgroundCommandDetail({ - command: "sleep 20 && echo CODEX_BG_WAKE_DONE", - exitCode: 0, - aggregatedOutput: "CODEX_BG_WAKE_DONE\n", - }), - "Background command completed (exit 0): sleep 20 && echo CODEX_BG_WAKE_DONE\n\n" + - "Output tail:\nCODEX_BG_WAKE_DONE", - ); - }); - - it("omits the output section and exit code when absent", () => { - assert.equal( - CodexAdapterV2.codexBackgroundCommandDetail({ - command: "sleep 20", - exitCode: null, - aggregatedOutput: null, - }), - "Background command completed: sleep 20", - ); - }); - - it("truncates long commands and keeps only the output tail", () => { - const detail = CodexAdapterV2.codexBackgroundCommandDetail({ - command: "x".repeat(300), - exitCode: 1, - aggregatedOutput: `${"y".repeat(2000)}TAIL`, - }); - assert.include(detail, `(exit 1): ${"x".repeat(200)}...`); - assert.include(detail, "Output tail:\n..."); - assert.include(detail, "TAIL"); - assert.notInclude(detail, "y".repeat(1001)); - }); -}); - -const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); -const CODEX_TEST_MODEL_SELECTION = { - instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - model: "gpt-5.4", -} satisfies ModelSelection; -const CODEX_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", -}); - -function makeCodexTestAppThread(input: { - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - readonly now: DateTime.Utc; -}): OrchestrationV2AppThread { - return { - createdBy: "user", - creationSource: "web", - id: input.threadId, - projectId: ProjectId.make(`project-${input.threadId}`), - title: "Codex continuation test", - providerInstanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: input.providerThread.id, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: input.threadId, - }, - forkedFrom: null, - createdAt: input.now, - updatedAt: input.now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; -} - -function makeCodexTestTurnInput(input: { - readonly threadId: ThreadId; - readonly providerThread: OrchestrationV2ProviderThread; - readonly now: DateTime.Utc; - readonly attemptId: RunAttemptId; - readonly text: string; -}): ProviderAdapter.ProviderAdapterV2TurnInput { - return { - appThread: makeCodexTestAppThread(input), - threadId: input.threadId, - runId: RunId.make(`run-${input.attemptId}`), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: input.attemptId, - rootNodeId: NodeId.make(`node-${input.attemptId}`), - providerThread: input.providerThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make(`message-${input.attemptId}`), - text: input.text, - attachments: [], - }, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }; -} - -function makeCodexReplayTurn(input: { - readonly id: string; - readonly status: "inProgress" | "completed" | "interrupted" | "failed"; -}): Record { - const terminal = - input.status === "completed" || input.status === "interrupted" || input.status === "failed"; - return { - id: input.id, - items: [], - itemsView: "notLoaded", - status: input.status, - error: null, - startedAt: 1782622440, - completedAt: terminal ? 1782622450 : null, - durationMs: null, - }; -} - -function codexReplayPreamble(input: { - readonly nativeThreadId: string; - readonly nativeTurnId: string; - readonly prompt: string; - readonly sentPrompt?: string; -}): Array { - return [ - { - type: "expect_outbound", - label: "initialize", - frame: { - id: 1, - method: "initialize", - params: { - clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: ["turn/diff/updated"], - extensions: { - "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, - }, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "initialize", - frame: { - id: 1, - result: { - userAgent: "T3 Code/0.156.1", - codexHome: "/tmp/codex-home", - platformFamily: "unix", - platformOs: "macos", - }, - }, - }, - { type: "expect_outbound", label: "initialized", frame: { method: "initialized" } }, - { - type: "expect_outbound", - label: "thread/start", - frame: { - id: 2, - method: "thread/start", - params: { config: CodexAdapterV2.CODEX_THREAD_CONFIG }, - }, - }, - { - type: "emit_inbound", - label: "thread/start", - frame: { - id: 2, - result: { - thread: { - id: input.nativeThreadId, - sessionId: input.nativeThreadId, - forkedFromId: null, - preview: "", - ephemeral: false, - modelProvider: "openai", - createdAt: 1782622440, - updatedAt: 1782622440, - status: { type: "idle" }, - path: `/tmp/${input.nativeThreadId}.jsonl`, - cwd: "/workspace", - cliVersion: "0.144.0", - source: "vscode", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - }, - model: "gpt-5.4", - modelProvider: "openai", - serviceTier: null, - cwd: "/workspace", - instructionSources: [], - approvalPolicy: "on-request", - approvalsReviewer: "user", - sandbox: { type: "workspaceWrite", writableRoots: [], networkAccess: false }, - reasoningEffort: "medium", - }, - }, - }, - { - type: "expect_outbound", - label: "turn/start", - frame: { - id: 3, - method: "turn/start", - params: { - threadId: input.nativeThreadId, - input: [{ type: "text", text: input.sentPrompt ?? input.prompt }], - cwd: "/workspace", - model: "gpt-5.4", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - summary: "detailed", - }, - }, - }, - { - type: "emit_inbound", - label: "turn/start", - frame: { - id: 3, - result: { turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }) }, - }, - }, - { - type: "emit_inbound", - label: "turn/started", - frame: { - method: "turn/started", - params: { - threadId: input.nativeThreadId, - turn: makeCodexReplayTurn({ id: input.nativeTurnId, status: "inProgress" }), - }, - }, - }, - ]; -} - -function makeCodexReplayTranscript(input: { - readonly scenario: string; - readonly entries: ReadonlyArray; -}): CodexReplay.CodexAppServerReplayTranscript { - return { - provider: "codex", - protocol: "codex.app-server", - version: "0.144.0", - scenario: input.scenario, - entries: input.entries, - }; -} - -function withReplayRequestId( - entry: CodexReplay.CodexAppServerReplayEntry, - id: number, -): CodexReplay.CodexAppServerReplayEntry { - return entry.type !== "runtime_exit" && Predicate.isObject(entry.frame) - ? { ...entry, frame: { ...entry.frame, id } } - : entry; -} - -describe("CodexAdapterV2 session initialize", () => { - const openReplaySession = ( - transcript: CodexReplay.CodexAppServerReplayTranscript, - beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], - ) => - Effect.gen(function* () { - const driver = yield* CodexReplay.makeReplayDriver( - transcript, - beforeEmitInbound === undefined ? {} : { beforeEmitInbound }, - ); - let initializeRequests = 0; - const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ - instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CODEX_SETTINGS, - environment: {}, - clientFactory: { - open: (openInput) => - Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( - Effect.flatMap((context) => - Effect.service(CodexClient.CodexAppServerClient).pipe(Effect.provide(context)), - ), - Effect.map( - (client) => - ({ - ...client, - request: (method, params) => - Effect.sync(() => { - if (method === "initialize") initializeRequests++; - }).pipe(Effect.andThen(client.request(method, params))), - }) satisfies CodexClient.CodexAppServerClient["Service"], - ), - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerSessionId: openInput.providerSessionId, - cause, - }), - ), - ), - }, - crypto: yield* Crypto.Crypto, - fileSystem: yield* FileSystem.FileSystem, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - }); - const runtime = yield* adapter.openSession({ - threadId: ThreadId.make(`thread-${transcript.scenario}`), - providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - return { - ensureThread: (threadId: string) => - runtime.ensureThread({ - threadId: ThreadId.make(threadId), - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }), - initializeRequests: () => initializeRequests, - }; - }); - - const replayPreamble = (nativeThreadId: string) => - codexReplayPreamble({ nativeThreadId, nativeTurnId: "unused", prompt: "unused" }); - - it.effect("sends one initialize when two threads start on a fresh session at once", () => - Effect.gen(function* () { - const initializeAwaitingResponse = yield* Deferred.make(); - const releaseInitialize = yield* Deferred.make(); - // The transcript allows exactly one handshake: a second `initialize` - // frame fails the replay, as Codex rejects it with "Already initialized". - const session = yield* openReplaySession( - makeCodexReplayTranscript({ - scenario: "concurrent-initialize", - entries: [ - ...replayPreamble("concurrent-first").slice(0, 5), - ...replayPreamble("concurrent-second") - .slice(3, 5) - .map((entry) => withReplayRequestId(entry, 3)), - ], - }), - (entry) => - entry.label === "initialize" - ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( - Effect.andThen(Deferred.await(releaseInitialize)), - ) - : Effect.void, - ); - - const first = yield* session - .ensureThread("thread-concurrent-first") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(initializeAwaitingResponse); - const second = yield* session - .ensureThread("thread-concurrent-second") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.succeed(releaseInitialize, undefined); - - const providerThreads = [yield* Fiber.join(first), yield* Fiber.join(second)]; - assert.equal(session.initializeRequests(), 1); - assert.sameMembers( - providerThreads.map((providerThread) => providerThread.nativeThreadRef?.nativeId), - ["concurrent-first", "concurrent-second"], - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("retries initialize after a failed handshake", () => - Effect.gen(function* () { - const preamble = replayPreamble("initialize-retry"); - const entries: Array = [ - ...preamble.slice(0, 1), - { - type: "emit_inbound", - label: "initialize", - frame: { id: 1, error: { code: -32603, message: "Codex is not ready." } }, - }, - ...preamble.slice(0, 2).map((entry) => withReplayRequestId(entry, 2)), - ...preamble.slice(2, 3), - ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), - ]; - const session = yield* openReplaySession( - makeCodexReplayTranscript({ scenario: "initialize-retry", entries }), - ); - - const failure = yield* session.ensureThread("thread-initialize-retry").pipe(Effect.flip); - assert.equal(failure._tag, "ProviderAdapterEnsureThreadError"); - const providerThread = yield* session.ensureThread("thread-initialize-retry"); - assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-retry"); - assert.equal(session.initializeRequests(), 2); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("completes the handshake after a caller is interrupted mid-initialize", () => - Effect.gen(function* () { - const initializeAwaitingResponse = yield* Deferred.make(); - const releaseInitialize = yield* Deferred.make(); - const preamble = replayPreamble("initialize-interrupted"); - const session = yield* openReplaySession( - makeCodexReplayTranscript({ - scenario: "initialize-interrupted", - entries: [ - ...preamble.slice(0, 2), - ...preamble.slice(0, 1).map((entry) => withReplayRequestId(entry, 2)), - { - type: "emit_inbound", - label: "initialize-rejected", - frame: { id: 2, error: { code: -32600, message: "Already initialized" } }, - }, - ...preamble.slice(2, 3), - ...preamble.slice(3, 5).map((entry) => withReplayRequestId(entry, 3)), - ], - }), - (entry) => - entry.label === "initialize" - ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( - Effect.andThen(Deferred.await(releaseInitialize)), - ) - : Effect.void, - ); - - const interrupted = yield* session - .ensureThread("thread-initialize-interrupted") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(initializeAwaitingResponse); - yield* Fiber.interrupt(interrupted); - yield* Deferred.succeed(releaseInitialize, undefined); - - const providerThread = yield* session.ensureThread("thread-initialize-interrupted"); - assert.equal(providerThread.nativeThreadRef?.nativeId, "initialize-interrupted"); - assert.equal(session.initializeRequests(), 2); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); -}); - -describe("CodexAdapterV2 post-settle continuation", () => { - const awaitUntil = (predicate: () => boolean, label: string): Effect.Effect => - Effect.gen(function* () { - for (let attempt = 0; attempt < 5000; attempt++) { - if (predicate()) { - return; - } - yield* Effect.yieldNow; - } - return yield* Effect.die(`Timed out waiting for ${label}.`); - }); - - const makeCodexReplayHarness = ( - transcript: CodexReplay.CodexAppServerReplayTranscript, - onEvent: (event: ProviderAdapter.ProviderAdapterV2Event) => Effect.Effect = () => - Effect.void, - onRequest: (method: string, params: unknown) => Effect.Effect = () => Effect.void, - readChildMetadata?: Parameters[2], - subscriptionLimitPolicy?: SubscriptionLimitPolicy.SubscriptionLimitPolicy, - ) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const serverConfig = yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie); - const continuationRequests: Array = - []; - const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = { - open: (openInput) => - Layer.build(CodexReplay.layerReplay(transcript)).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - providerSessionId: openInput.providerSessionId, - cause, - }), - ), - Effect.flatMap((context) => - Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.map((client) => - withCodexReplayChildMetadata(client, transcript, readChildMetadata), - ), - Effect.map( - (client) => - ({ - ...client, - request: (method, params) => - onRequest(method, params).pipe( - Effect.andThen(client.request(method, params)), - ), - }) satisfies CodexClient.CodexAppServerClient["Service"], - ), - Effect.provide(context), - ), - ), - ), - }; - const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({ - instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CODEX_SETTINGS, - environment: {}, - clientFactory, - crypto: yield* Crypto.Crypto, - fileSystem, - idAllocator, - serverConfig, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }), - }, - ...(subscriptionLimitPolicy === undefined ? {} : { subscriptionLimitPolicy }), - }); - const threadId = ThreadId.make(`thread-${transcript.scenario}`); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make(`provider-session-${transcript.scenario}`), - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - const events: Array = []; - const firstTerminal = yield* Deferred.make(); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe( - Effect.andThen( - event.type === "turn.terminal" - ? Deferred.succeed(firstTerminal, undefined) - : Effect.void, - ), - Effect.andThen(onEvent(event)), - ), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die("Codex adapter runtime must expose hasPendingBackgroundWork."); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const terminalEvents = () => - events.filter( - ( - event, - ): event is Extract => - event.type === "turn.terminal", - ); - const subagentUpdates = () => - events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "subagent.updated" } - > => event.type === "subagent.updated", - ); - return { - runtime, - providerThread, - threadId, - serverConfig, - events, - continuationRequests, - terminalEvents, - subagentUpdates, - hasPendingBackgroundWork, - firstTerminal: Deferred.await(firstTerminal), - }; - }); - - it.effect.each(["supported", "unsupported", "invalid"] as const)( - "delivers native history with %s app-server protocol", - (response) => - Effect.gen(function* () { - const nativeThreadId = `inject-${response}`; - const prompt = "Only the current request"; - const history: ProviderAdapter.ProviderAdapterV2HistoricalContext = { - context: "Historical conversation", - messages: (["user", "assistant"] as const).map((role) => ({ - role, - text: - role === "user" - ? "Original request\n" + "界".repeat(300) - : "Partial interrupted work", - threadId: ThreadId.make("source"), - runId: RunId.make("source-run"), - itemId: TurnItemId.make(`source-${role}`), - providerThreadId: null, - kind: `${role}_message`, - status: "interrupted", - })), - }; - const items = historyResponseItems(history.messages, history.context); - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "current-turn", - prompt, - }); - const transcript = makeCodexReplayTranscript({ - scenario: `inject-${response}`, - entries: [ - ...preamble.slice(0, -3), - { - type: "expect_outbound", - label: "inject", - frame: { - id: 3, - method: "thread/inject_items", - params: { threadId: nativeThreadId, items }, - }, - }, - { - type: "emit_inbound", - label: "inject-result", - frame: - response === "supported" - ? { id: 3, result: {} } - : { - id: 3, - error: { - code: response === "unsupported" ? -32601 : -32602, - message: "Injection rejected", - }, - }, - }, - ...(response === "invalid" - ? [] - : preamble - .slice(-3) - .map((entry) => - "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame - ? { ...entry, frame: { ...entry.frame, id: 4 } } - : entry, - )), - ], - }); - const requests: string[] = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - requests.push(method); - }), - ); - const injection = yield* harness.runtime.injectHistory!({ - providerThread: harness.providerThread, - ...history, - }).pipe(Effect.result); - if (response === "invalid") { - assert.equal(injection._tag, "Failure"); - if (injection._tag === "Failure") { - assert.equal(injection.failure._tag, "ProviderAdapterProtocolError"); - assert.propertyVal(injection.failure.cause, "code", -32602); - assert.notProperty(injection.failure, "payload"); - } - assert.notInclude(requests, "turn/start"); - return; - } - assert.equal(injection._tag, "Success"); - if (injection._tag === "Success") assert.equal(injection.success, response === "supported"); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("inject-attempt"), - text: prompt, - }), - ); - assert.equal(requests.filter((method) => method === "turn/start").length, 1); - assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("adds the session MCP server without replacing launch-argument servers", () => - Effect.gen(function* () { - const scenario = "launch-mcp-servers"; - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("environment-codex-mcp"), - threadId: ThreadId.make(`thread-${scenario}`), - providerSessionId: `provider-session-${scenario}`, - providerInstanceId: ProviderInstanceId.make("codex"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer secret-codex-token", - browserToolsAvailable: true, - }); - const requests: Array = []; - yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario, - entries: codexReplayPreamble({ - nativeThreadId: "launch-mcp-thread", - nativeTurnId: "unused", - prompt: "unused", - }).slice(0, 5), - }), - () => Effect.void, - (method, params) => - Effect.sync(() => { - if (method === "thread/start") requests.push(params); - }), - ); - assert.deepEqual(requests, [ - { - cwd: "/workspace", - model: "gpt-5.4", - config: { - "tools.update_plan.enabled": true, - mcp_optional_startup_grace_ms: 10_000, - "mcp_servers.t3-code": { - url: "http://127.0.0.1:43123/mcp", - tool_timeout_sec: 3_900, - http_headers: { Authorization: "Bearer secret-codex-token" }, - }, - }, - }, - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("replays historical and dotted MCP config while enforcing other thread config", () => - Effect.gen(function* () { - const recordedServer = { - url: "http://127.0.0.1:41000/mcp", - http_headers: { Authorization: "Bearer recorded-token" }, - }; - const currentConfig = { - "tools.update_plan.enabled": true, - "mcp_servers.t3-code": { - url: "http://127.0.0.1:42000/mcp", - http_headers: { Authorization: "Bearer current-token" }, - }, - "mcp_servers.extra.command": "cat", - }; - for (const recordedMcp of [ - { mcp_servers: { "t3-code": recordedServer } }, - { "mcp_servers.t3-code": recordedServer, "mcp_servers.extra.command": "echo" }, - ]) { - const entries = codexReplayPreamble({ - nativeThreadId: "replayed-mcp-thread", - nativeTurnId: "unused", - prompt: "unused", - }) - .slice(3, 5) - .map((entry) => withReplayRequestId(entry, 1)); - entries[0] = { - type: "expect_outbound", - frame: { - id: 1, - method: "thread/start", - params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, - }, - }; - entries.push({ - ...entries[0], - frame: { - id: 2, - method: "thread/start", - params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, - }, - }); - const driver = yield* CodexReplay.makeReplayDriver( - makeCodexReplayTranscript({ - scenario: "mcp-config-normalization", - entries, - }), - ); - yield* Effect.gen(function* () { - const client = yield* CodexClient.CodexAppServerClient; - const response = yield* client.request("thread/start", { config: currentConfig }); - assert.equal(response.thread.id, "replayed-mcp-thread"); - assert.isNull((yield* Ref.get(driver.state)).failure); - yield* client - .request("thread/start", { - config: { ...currentConfig, "tools.update_plan.enabled": false }, - }) - .pipe(Effect.flip); - assert.instanceOf( - (yield* Ref.get(driver.state)).failure, - CodexReplay.CodexAppServerReplayFrameMismatchError, - ); - }).pipe(Effect.provide(CodexReplay.layerReplayWithDriver(driver))); - } - }).pipe(Effect.scoped), - ); - - it.effect("identifies sessions to Codex with the same client info as main", () => - Effect.gen(function* () { - const transcript = makeCodexReplayTranscript({ - scenario: "initialize-client-info", - entries: codexReplayPreamble({ - nativeThreadId: "client-info-thread", - nativeTurnId: "unused", - prompt: "unused", - }).slice(0, 5), - }); - const initializeParams: Array = []; - yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method, params) => - Effect.sync(() => { - if (method === "initialize") initializeParams.push(params); - }), - ); - assert.deepEqual(initializeParams, [ - { - clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, - capabilities: { - experimentalApi: true, - optOutNotificationMethods: ["turn/diff/updated"], - extensions: { - "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, - }, - }, - }, - ]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("unsubscribes from the native thread when it is unloaded", () => - Effect.gen(function* () { - const nativeThreadId = "unload-thread"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "unload-thread", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/unsubscribe", - frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "thread/unsubscribe", - frame: { id: 3, result: { status: "unsubscribed" } }, - }, - ], - }); - const requests: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => Effect.sync(() => requests.push(method)), - ); - assert.isDefined(harness.runtime.unloadThread); - yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); - assert.deepEqual(requests, ["initialize", "thread/start", "thread/unsubscribe"]); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("keeps the app-server failure as the cause when an unload is rejected", () => - Effect.gen(function* () { - const nativeThreadId = "unload-thread-rejected"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused", - prompt: "unused", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "unload-thread-rejected", - entries: [ - ...preamble.slice(0, 5), - { - type: "expect_outbound", - label: "thread/unsubscribe", - frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "thread/unsubscribe", - frame: { id: 3, error: { code: -32600, message: "invalid thread id" } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const error = yield* harness.runtime.unloadThread!({ - providerThread: harness.providerThread, - }).pipe(Effect.flip); - assert.equal(error._tag, "ProviderAdapterProtocolError"); - const cause = error._tag === "ProviderAdapterProtocolError" ? error.cause : undefined; - assert.equal((cause as { _tag?: string } | undefined)?._tag, "CodexAppServerRequestError"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("waits for native start before interrupting an acknowledged queued turn", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-await-native-start", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/started", - afterMs: 1000, - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, - }, - { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("500 millis"); - assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(interrupt); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("settles Stop when a queued native turn fails before starting", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-failed-before-native-start", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/failed", - afterMs: 1000, - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - startedAt: null, - error: { - message: "Failed before native start", - codexErrorInfo: null, - additionalDetails: null, - }, - }, - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("500 millis"); - assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(interrupt); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(interruptSent, "A terminal native turn must not receive turn/interrupt"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("bounds Stop when a queued native turn never starts", () => - Effect.gen(function* () { - const nativeThreadId = "early-stop-thread"; - const nativeTurnId = "early-stop-turn"; - const prompt = "Run a command."; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); - const transcript = makeCodexReplayTranscript({ - scenario: "early-stop-never-starts", - entries: [ - ...preamble.slice(0, -2), - { - type: "emit_inbound", - label: "turn/start/queued", - frame: { - id: 3, - result: { - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - startedAt: null, - }, - }, - }, - }, - ], - }); - let interruptSent = false; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method) => - Effect.sync(() => { - if (method === "turn/interrupt") interruptSent = true; - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("early-stop-attempt"), - text: prompt, - }), - ); - const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("10 seconds"); - assert.equal((yield* Fiber.join(interrupt))._tag, "Failure"); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(interruptSent, "An unstarted native turn must not receive turn/interrupt"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("sends currency-sigil skill mentions to Codex as $ mentions", () => - Effect.gen(function* () { - const nativeThreadId = "skill-sigil-thread"; - const nativeTurnId = "skill-sigil-turn"; - const transcript = makeCodexReplayTranscript({ - scenario: "skill-sigil-canonicalized", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "€review do it", - sentPrompt: "$review do it", - }), - { - type: "expect_outbound", - label: "turn/steer", - frame: { - id: 4, - method: "turn/steer", - params: { - expectedTurnId: nativeTurnId, - input: [{ type: "text", text: "then $ship it" }], - threadId: nativeThreadId, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/steer", - frame: { id: 4, result: { turnId: nativeTurnId } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const turnInput = makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("skill-sigil-attempt"), - text: "€review do it", - }); - yield* harness.runtime.startTurn(turnInput); - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: turnInput.runId, - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: CodexAdapterV2.CODEX_DRIVER_KIND, - nativeTurnId, - }), - message: { - ...turnInput.message, - messageId: MessageId.make("message-skill-sigil-steer"), - text: "then £ship it", - }, - }); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - const assistantMessages = (events: ReadonlyArray) => - events.filter( - ( - event, - ): event is Extract => - event.type === "message.updated" && event.message.role === "assistant", - ); - const assistantTurnItems = (events: ReadonlyArray) => - events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" - ? [event.turnItem] - : [], - ); - - it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "async-question-thread"; - const nativeTurnId = "async-question-turn"; - const usage = { - totalTokens: 15, - inputTokens: 10, - cachedInputTokens: 2, - outputTokens: 5, - reasoningOutputTokens: 1, - }; - const transcript = makeCodexReplayTranscript({ - scenario: "async-question-and-billed-usage", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Continue while I decide.", - }), - { - type: "emit_inbound", - label: "question", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "agentMessage", - id: "async-question-item", - text: "Which branch?", - delivery: "async", - questions: [{ title: "Which branch?", options: ["main", "dev"] }], - }, - }, - }, - }, - { - type: "emit_inbound", - label: "usage", - frame: { - method: "thread/tokenUsage/updated", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, - }, - }, - }, - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("async-question-attempt"), - text: "Continue while I decide.", - }), - ); - yield* harness.firstTerminal; - const requests = harness.events.flatMap((event) => - event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], - ); - assert.lengthOf(requests, 1); - assert.equal(requests[0]?.status, "pending"); - assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); - const questionItem = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - assert.equal(questionItem?.type, "turn_item.updated"); - if ( - questionItem?.type === "turn_item.updated" && - questionItem.turnItem.type === "user_input_request" - ) { - assert.deepEqual( - questionItem.turnItem.questions[0]?.options.map((option) => option.label), - ["main", "dev"], - ); - assert.equal(questionItem.turnItem.responseMode, "message"); - } - const questionNode = harness.events.find( - (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, - ); - assert.equal( - questionNode?.type === "node.updated" && questionNode.node.countsForRun, - false, - ); - const contextReport = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, - ); - assert.equal(contextReport?.type, "provider_turn.updated"); - if (contextReport?.type === "provider_turn.updated") { - assert.equal( - contextReport.providerTurn.runAttemptId, - RunAttemptId.make("async-question-attempt"), - ); - assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); - assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); - assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); - } - const completed = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - assert.equal(completed?.type, "provider_turn.updated"); - if (completed?.type === "provider_turn.updated") { - assert.deepEqual(completed.providerTurn.turnTokenUsage, { - usageStatus: "complete", - usageScope: "main_agent", - hasSubagents: false, - inputTokens: 10, - cachedInputTokens: 2, - outputTokens: 5, - reasoningTokens: 1, - }); - } - assert.isEmpty(assistantMessages(harness.events)); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("stops asking for commands the user allowed for the session", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "session-approval-thread"; - const turnIds = [ - "session-approval-turn-1", - "session-approval-turn-2", - "session-approval-turn-3", - ]; - const turnPolicies = { - "approval-required": { - approvalPolicy: "untrusted", - approvalsReviewer: "user", - sandboxPolicy: { type: "readOnly" }, - }, - "full-access": { - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - }, - } as const; - const turnModes = ["approval-required", "full-access", "approval-required"] as const; - const turnText = (index: number) => `Review round ${index + 1}.`; - const startTurn = (index: number): ReadonlyArray => [ - { - type: "expect_outbound", - label: `turn ${index + 1} turn/start`, - frame: { - id: 3 + index, - method: "turn/start", - params: { - threadId: nativeThreadId, - input: [{ type: "text", text: turnText(index) }], - cwd: "/workspace", - model: "gpt-5.4", - ...turnPolicies[turnModes[index]!], - summary: "detailed", - }, - }, - }, - { - type: "emit_inbound", - label: `turn ${index + 1} turn/start`, - frame: { - id: 3 + index, - result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, - }, - }, - turnEvent("turn/started", turnIds[index]!), - ]; - const approval = (input: { - readonly id: number; - readonly turnId: string; - readonly kind: "commandExecution" | "fileChange"; - readonly itemId: string; - readonly decision: string; - }): ReadonlyArray => [ - { - type: "emit_inbound", - label: `${input.itemId} approval`, - frame: { - id: input.id, - method: `item/${input.kind}/requestApproval`, - params: { - threadId: nativeThreadId, - turnId: input.turnId, - itemId: input.itemId, - startedAtMs: 1782622441000, - ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), - }, - }, - }, - { - type: "expect_outbound", - label: `${input.itemId} decision`, - frame: { id: input.id, result: { decision: input.decision } }, - }, - ]; - const turnEvent = ( - method: "turn/started" | "turn/completed", - turnId: string, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `${turnId} ${method}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ - id: turnId, - status: method === "turn/started" ? "inProgress" : "completed", - }), - }, - }, - }); - const [patchRequest, patchDecision] = approval({ - id: 102, - turnId: turnIds[0]!, - kind: "fileChange", - itemId: "first-patch", - decision: "acceptForSession", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-session-approvals", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: turnIds[0]!, - prompt: turnText(0), - }).slice(0, -3), - ...startTurn(0), - ...approval({ - id: 100, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "first-command", - decision: "acceptForSession", - }), - patchRequest!, - ...approval({ - id: 101, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "second-command", - decision: "acceptForSession", - }), - turnEvent("turn/completed", turnIds[0]!), - ...startTurn(1), - patchDecision!, - turnEvent("turn/completed", turnIds[1]!), - ...startTurn(2), - ...approval({ - id: 103, - turnId: turnIds[2]!, - kind: "commandExecution", - itemId: "third-command", - decision: "accept", - }), - ...approval({ - id: 104, - turnId: turnIds[2]!, - kind: "fileChange", - itemId: "second-patch", - decision: "accept", - }), - turnEvent("turn/completed", turnIds[2]!), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const requests = () => [ - ...new Map( - harness.events.flatMap((event) => - event.type === "runtime_request.updated" - ? [[event.runtimeRequest.id, event.runtimeRequest] as const] - : [], - ), - ).values(), - ]; - const answer = ( - count: number, - kind: "command" | "file-change", - decision: ProviderApprovalDecision, - ) => - Effect.gen(function* () { - yield* awaitUntil(() => requests().length === count, `approval request ${count}`); - const request = requests()[count - 1]!; - assert.equal(request.kind, kind); - yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - }); - - const runTurn = (index: number) => - Effect.gen(function* () { - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), - text: turnText(index), - }), - runOrdinal: index + 1, - providerTurnOrdinal: index + 1, - runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, - }); - }); - - yield* runTurn(0); - yield* answer(1, "command", "acceptForSession"); - yield* awaitUntil(() => requests().length === 2, "file change request"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn 1"); - yield* runTurn(1); - yield* answer(2, "file-change", "acceptForSession"); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "turn 2"); - yield* runTurn(2); - yield* answer(3, "command", "accept"); - yield* answer(4, "file-change", "accept"); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "turn 3"); - assert.lengthOf(requests(), 4); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves T3 context on the wire and restores it after compaction", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "context-thread"; - const nativeTurnId = "context-turn"; - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId, - codexInput: [{ type: "text", text: "work" }], - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - modelSelection: CODEX_TEST_MODEL_SELECTION, - hasT3Mcp: true, - }); - assert.include( - params.additionalContext?.t3_code_orchestration_01?.value ?? "", - "delegate_task", - ); - const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); - const transcript = makeCodexReplayTranscript({ - scenario: "restore-context", - entries: [ - ...entries.slice(0, 5), - { - type: "expect_outbound", - label: "context turn", - frame: { id: 3, method: "turn/start", params }, - }, - ...entries.slice(6), - { - type: "emit_inbound", - label: "compacted", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "contextCompaction", id: "compact-context" }, - }, - }, - }, - { - type: "expect_outbound", - label: "restore context", - frame: { - id: 4, - method: "thread/inject_items", - params: { - threadId: nativeThreadId, - items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ - type: "message", - role: "developer", - content: [{ type: "input_text", text: `<${key}>${entry.value}` }], - })), - }, - }, - }, - { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("test"), - threadId: harness.threadId, - providerSessionId: "context-session", - providerInstanceId: ProviderInstanceId.make("codex"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer test", - browserToolsAvailable: true, - }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("context-attempt"), - text: "work", - }), - ); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("compacts Codex with the native RPC and completes the compaction turn", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const item = { type: "contextCompaction", id: "compact-item" }; - const transcript = makeCodexReplayTranscript({ - scenario: "native-compaction", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), - { - type: "expect_outbound", - label: "compact", - frame: { - id: 3, - method: "thread/compact/start", - params: { threadId: nativeThreadId }, - }, - }, - { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, - { - type: "emit_inbound", - label: "start", - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - }, - }, - }, - ...(["item/started", "item/completed"] as const).map((method) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, - })), - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - assert.isDefined(harness.runtime.compactThread); - yield* harness.runtime.compactThread!( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("compact-attempt"), - text: "/compact", - }), - ); - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - items.map((entry) => entry.status), - ["running", "completed"], - ); - assert.equal(items[0]?.id, items[1]?.id); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["failed", "interrupted", "Stop"] as const)( - "settles unfinished compaction on %s without changing completed compaction", - (outcome) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const error = { - message: "Remote compaction stream disconnected", - codexErrorInfo: "other", - additionalDetails: null, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `automatic-compaction-${outcome}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...( - [ - { method: "item/started", id: "completed-item", startedAtMs: 1782622441000 }, - { method: "item/completed", id: "completed-item", completedAtMs: 1782622442000 }, - { method: "item/started", id: "unfinished-item", startedAtMs: 1782622443000 }, - ] as const - ).map(({ method, id, ...timestamps }) => ({ - type: "emit_inbound" as const, - label: `${method}/${id}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "contextCompaction", id }, - ...timestamps, - }, - }, - })), - ...(outcome === "Stop" - ? [ - { - type: "expect_outbound" as const, - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, - }, - { - type: "emit_inbound" as const, - label: "turn/interrupt/failed", - frame: { id: 4, error: { code: -32_000, message: "Interrupt failed" } }, - }, - ] - : [ - { - type: "emit_inbound" as const, - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: outcome }), - error: outcome === "failed" ? error : null, - }, - }, - }, - }, - ]), - ], - }); - const compactionStarted = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && - event.turnItem.status === "running" && - event.turnItem.providerTurnId !== null - ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) - : Effect.void, - ); - yield* TestClock.setTime(1782622445000); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`compact-${outcome}-attempt`), - text: "Continue.", - }), - ); - if (outcome === "Stop") { - const providerTurnId = yield* Deferred.await(compactionStarted); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit); - assert.equal(interrupt._tag, "Failure"); - } - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - const completed = items.filter( - (item) => item.nativeItemRef?.nativeId === "completed-item", - ); - const unfinished = items.filter( - (item) => item.nativeItemRef?.nativeId === "unfinished-item", - ); - const status = outcome === "failed" ? "failed" : "interrupted"; - assert.deepEqual( - unfinished.map((item) => item.status), - ["running", status], - ); - assert.equal(unfinished[0]?.id, unfinished[1]?.id); - assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); - assert.deepEqual( - unfinished[1]?.completedAt, - DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), - ); - assert.deepEqual( - completed.map((item) => item.status), - ["running", "completed"], - ); - assert.deepEqual(completed[1]?.startedAt, DateTime.makeUnsafe(1782622441000)); - assert.deepEqual(completed[1]?.completedAt, DateTime.makeUnsafe(1782622442000)); - assert.equal(harness.terminalEvents()[0]?.status, status); - if (outcome === "failed") { - assert.equal(harness.terminalEvents()[0]?.failure?.message, error.message); - } - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("resumes a provider thread without requesting or decoding its history", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-resume-metadata"; - const nativeThreadId = `native-${scenario}-thread`; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...preamble, - { - type: "expect_outbound", - label: "thread/resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "thread/resume", - frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each( - ( - [ - ["recovers archived-session errors", "session saved-thread is archived", 0, ""], - [ - "recovers unarchive hints", - "Run `codex unarchive saved-thread` to unarchive it first.", - 0, - "", - ], - ["preserves missing-thread errors", "thread not found", 3, "thread not found"], - ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"], - ["preserves authentication errors", "authentication failed", 3, "authentication failed"], - [ - "preserves archived-workspace errors", - "workspace is archived", - 3, - "workspace is archived", - ], - [ - "preserves unrelated archive-path errors", - "permission denied reading archived_sessions/saved-thread", - 3, - "permission denied reading archived_sessions/saved-thread", - ], - [ - "propagates unarchive missing-thread errors", - "session saved-thread is archived", - 4, - "thread not found", - ], - [ - "propagates unarchive archived errors", - "session saved-thread is archived", - 4, - "session saved-thread is archived", - ], - [ - "propagates retry missing-thread errors", - "session saved-thread is archived", - 5, - "thread not found", - ], - [ - "does not retry archived errors twice", - "session saved-thread is archived", - 5, - "session saved-thread is archived", - ], - ] as const - ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), - )("$name", ({ name, resumeError, failAt, finalError }) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "saved-thread"; - const params = { - threadId: nativeThreadId, - excludeTurns: true, - cwd: CODEX_TEST_RUNTIME_POLICY.cwd, - model: CODEX_TEST_MODEL_SELECTION.model, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }; - const entries: Array = [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5), - { - type: "expect_outbound", - label: "resume archived thread", - frame: { id: 3, method: "thread/resume", params }, - }, - { - type: "emit_inbound", - label: "resume error", - frame: { id: 3, error: { code: -32600, message: resumeError } }, - }, - ]; - if (failAt !== 3) { - entries.push( - { - type: "expect_outbound", - label: "unarchive same thread", - frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, - }, - { - type: "emit_inbound", - label: "unarchive result", - frame: - failAt === 4 - ? { id: 4, error: { code: -32600, message: finalError } } - : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, - }, - ); - } - if (failAt === 0 || failAt === 5) { - entries.push( - { - type: "expect_outbound", - label: "retry identical resume", - frame: { id: 5, method: "thread/resume", params }, - }, - { - type: "emit_inbound", - label: "retry result", - frame: - failAt === 5 - ? { id: 5, error: { code: -32600, message: finalError } } - : { - id: 5, - result: { - thread: { - id: nativeThreadId, - updatedAt: 1782622450, - turns: [{ type: "unknown-history-item" }], - }, - }, - }, - }, - ); - } - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ scenario: name, entries }), - ); - const resume = harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - if (failAt !== 0) { - const error = yield* Effect.flip(resume); - assert.equal(error._tag, "ProviderAdapterResumeThreadError"); - assert.nestedPropertyVal(error, "cause.errorMessage", finalError); - assert.nestedPropertyVal( - error, - "cause.method", - failAt === 4 ? "thread/unarchive" : "thread/resume", - ); - assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); - return; - } - const resumed = yield* resume; - assert.equal(resumed.id, harness.providerThread.id); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.deepEqual( - resumed.nativeConversationHeadRef, - harness.providerThread.nativeConversationHeadRef, - ); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("continues an interrupted native thread with empty input and reasoning summaries", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-restart-promptless"; - const nativeThreadId = "native-restart-promptless"; - const nativeTurnId = "turn-restart-promptless"; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "unused", - }).slice(0, 5); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...preamble, - { - type: "expect_outbound", - label: "resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "resume", - frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - { - type: "expect_outbound", - label: "continue", - frame: { - id: 4, - method: "turn/start", - params: { - threadId: nativeThreadId, - input: [], - cwd: "/workspace", - model: "gpt-5.4", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - summary: "detailed", - }, - }, - }, - { - type: "emit_inbound", - label: "continue", - frame: { - id: 4, - result: { turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }) }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: resumed, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-restart-promptless"), - text: "Continue where you left off.", - }), - restartContinuationOfRunId: RunId.make("run-before-restart"), - }); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("resolves retryable app-server errors on resumed provider activity", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-provider-api-retry"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Open github.com.", - }), - { - type: "emit_inbound", - label: "error/retry", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... 2/5", - additionalDetails: "The response stream disconnected.", - codexErrorInfo: { - responseStreamDisconnected: { httpStatusCode: 529 }, - }, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "item/started/after-retry", - frame: { - method: "item/started", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622445000, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "inProgress", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/after-retry", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622445010, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace\n", - exitCode: 0, - durationMs: 10, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), - text: "Open github.com.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex retry recovery"); - - const retryItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem] - : [], - ); - assert.lengthOf(retryItems, 2); - assert.equal(retryItems[0]?.status, "running"); - assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); - assert.deepEqual(retryItems[0]?.retry, { - attempt: 2, - maxAttempts: 5, - retryDelayMs: null, - }); - assert.equal(retryItems[1]?.id, retryItems[0]?.id); - assert.equal(retryItems[1]?.status, "completed"); - assert.equal(retryItems[1]?.title, "Provider recovered"); - - const recoveredIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.status === "completed", - ); - const resumedCommandIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.input === "pwd", - ); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - assert.isAtLeast(recoveredIndex, 0); - assert.isAbove(resumedCommandIndex, recoveredIndex); - assert.isAbove(terminalIndex, resumedCommandIndex); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("stamps Codex items with their own start time, not the turn's", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-item-start-times"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const commandLifecycle = (id: string, startedAtMs: number) => - (["started", "completed"] as const).map((phase) => ({ - type: "emit_inbound" as const, - label: `item/${phase}/${id}`, - frame: { - method: `item/${phase}`, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - ...(phase === "started" ? { startedAtMs } : { completedAtMs: startedAtMs + 10 }), - item: { - type: "commandExecution", - id, - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: phase === "started" ? "inProgress" : "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: phase === "started" ? null : "/workspace\n", - exitCode: phase === "started" ? null : 0, - durationMs: phase === "started" ? null : 10, - }, - }, - }, - })); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Run two commands." }), - ...commandLifecycle("first-command", 1782622445000), - ...commandLifecycle("second-command", 1782622505000), - ...(["started", "completed"] as const).map((phase) => ({ - type: "emit_inbound" as const, - label: `item/${phase}/compaction`, - frame: { - method: `item/${phase}`, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - ...(phase === "started" - ? { startedAtMs: 1782622565000 } - : { completedAtMs: 1782622575000 }), - item: { type: "contextCompaction", id: "compaction" }, - }, - }, - })), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-item-start-times"), - text: "Run two commands.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "Codex item start times"); - - const startedAtByItem = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - (event.turnItem.type === "command_execution" || event.turnItem.type === "compaction") - ? [[event.turnItem.nativeItemRef?.nativeId, event.turnItem.startedAt] as const] - : [], - ); - assert.deepEqual( - startedAtByItem.map(([id, startedAt]) => [id, startedAt?.epochMilliseconds]), - [ - ["first-command", 1782622445000], - ["first-command", 1782622445000], - ["second-command", 1782622505000], - ["second-command", 1782622505000], - ["compaction", 1782622565000], - ["compaction", 1782622565000], - ], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each(["completed", "interrupted"] as const)( - "retains Codex reasoning parts when the turn is %s", - (terminalStatus) => - Effect.scoped( - Effect.gen(function* () { - const scenario = `codex-reasoning-${terminalStatus}`; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Explain the check."; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...[ - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "one" }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, - }, - { - method: "item/reasoning/textDelta", - params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, - }, - { - method: "item/completed", - params: { - item: { - type: "commandExecution", - id: "after-thought", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace", - exitCode: 0, - durationMs: 1, - }, - }, - }, - ...(terminalStatus === "completed" - ? [ - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "thought", - summary: ["Final summary one", "Summary two"], - content: ["Raw trace"], - }, - }, - }, - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "completion-only", - summary: ["Completion without deltas"], - content: [], - }, - }, - }, - { - method: "item/reasoning/textDelta", - params: { - itemId: "delta-only", - contentIndex: 0, - delta: "Retained when completion omits content", - }, - }, - { - method: "item/completed", - params: { - item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, - }, - }, - ] - : []), - ].map((event, index) => ({ - type: "emit_inbound" as const, - label: `reasoning-${index}`, - frame: { - method: event.method, - params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, - }, - })), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${scenario}`), - text: prompt, - }), - ); - yield* harness.firstTerminal; - const latest = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "reasoning" - ? [[event.turnItem.id, event.turnItem] as const] - : [], - ), - ); - assert.deepEqual( - [...latest.values()].map((item) => item.text), - terminalStatus === "completed" - ? [ - "Final summary one", - "Summary two", - "Raw trace", - "Completion without deltas", - "Retained when completion omits content", - ] - : ["Summary one", "Summary two", "Raw trace"], - ); - assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); - assert.isTrue([...latest.values()].every((item) => item.streaming === false)); - assert.isTrue( - [...latest.values()].every( - (item) => item.runId !== null && item.providerTurnId !== null, - ), - ); - assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); - const command = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - assert.isDefined(command); - if (command?.type === "turn_item.updated") { - assert.isTrue( - [...latest.values()] - .slice(0, 3) - .every((item) => item.ordinal < command.turnItem.ordinal), - ); - } - assert.deepEqual(assistantMessages(harness.events), []); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("streams a proposed plan a few times a second instead of once per delta", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-plan-delta-coalescing"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Plan the change."; - const deltas = Array.from({ length: 100 }, (_, index) => `step ${index}\n`); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...deltas.map((delta, index): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `item/plan/delta/${index}`, - afterMs: 20, - frame: { - method: "item/plan/delta", - params: { threadId: nativeThreadId, turnId: nativeTurnId, itemId: "plan", delta }, - }, - })), - { - type: "emit_inbound", - label: "item/completed/plan", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "plan", id: "plan", text: "" }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${scenario}`), - text: prompt, - }), - ); - for (let step = 0; step < 200 && harness.terminalEvents().length === 0; step += 1) { - yield* Effect.yieldNow; - yield* TestClock.adjust("20 millis"); - } - yield* harness.firstTerminal; - - const plans = harness.events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], - ); - const planItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "proposed_plan" - ? [event.turnItem] - : [], - ); - assert.isAtLeast(plans.length, 2); - assert.isAtMost(plans.length, 7); - assert.equal(planItems.length, plans.length); - assert.equal(plans.at(-1)?.markdown, deltas.join("")); - assert.deepEqual( - planItems.map((item) => item.streaming), - [...Array.from({ length: planItems.length - 1 }, () => true), false], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const finalAnswerTranscript = ( - scenario: string, - answers: ReadonlyArray<{ - readonly id: string; - readonly text: string; - readonly phase?: "commentary" | "final_answer" | null; - readonly omitPhase?: boolean; - readonly streamed?: boolean; - readonly completionDelayMs?: number; - }>, - ) => { - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Reply with the requested recovery marker."; - return makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...answers.flatMap( - (answer, index): ReadonlyArray => { - const phase = answer.omitPhase - ? {} - : { phase: answer.phase === undefined ? ("final_answer" as const) : answer.phase }; - const completed: CodexReplay.CodexAppServerReplayEntry = { - type: "emit_inbound", - label: `item/completed/${answer.id}`, - ...(answer.completionDelayMs === undefined - ? {} - : { afterMs: answer.completionDelayMs }), - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: answer.id, - text: answer.text, - ...phase, - memoryCitation: null, - }, - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441000 + index, - }, - }, - }; - if (!answer.streamed) { - return [completed]; - } - return [ - { - type: "emit_inbound", - label: `item/started/${answer.id}`, - frame: { - method: "item/started", - params: { - item: { - type: "agentMessage", - id: answer.id, - text: "", - ...phase, - memoryCitation: null, - }, - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622440500 + index, - }, - }, - }, - { - type: "emit_inbound", - label: `item/agentMessage/delta/${answer.id}`, - frame: { - method: "item/agentMessage/delta", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId: answer.id, - delta: answer.text, - }, - }, - }, - completed, - ]; - }, - ), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - }; - - it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("streams text on the turn item and sends the message once, when it completes", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-streamed-message-once", [ - { id: "answer", text: "CODEX_RECOVERY_OK", streamed: true, completionDelayMs: 100 }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-streamed-message-once"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* awaitUntil( - () => assistantTurnItems(harness.events).length === 1, - "streamed turn item", - ); - assert.deepEqual( - assistantTurnItems(harness.events).map(({ text, streaming }) => ({ text, streaming })), - [{ text: "CODEX_RECOVERY_OK", streaming: true }], - ); - assert.deepEqual(assistantMessages(harness.events), []); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.deepEqual( - assistantMessages(harness.events).map(({ message }) => ({ - text: message.text, - streaming: message.streaming, - })), - [{ text: "CODEX_RECOVERY_OK", streaming: false }], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a later streamed duplicate final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-streamed-duplicate-final", [ - { id: "answer-original", text: "CODEX_RECOVERY_OK" }, - { - id: "answer-duplicate", - text: "CODEX_RECOVERY_OK", - streamed: true, - completionDelayMs: 100, - }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-streamed-duplicate-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => assistantMessages(harness.events).length === 1, "original answer"); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("buffers an overlapping later final stream until duplicate detection", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-overlapping-duplicate-final"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const answerItem = (id: string, text: string) => ({ - type: "agentMessage" as const, - id, - text, - phase: "final_answer" as const, - memoryCitation: null, - }); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Reply with the requested recovery marker.", - }), - ...["answer-overlap-original", "answer-overlap-duplicate"].flatMap( - (itemId, index): ReadonlyArray => [ - { - type: "emit_inbound", - label: `item/started/${itemId}`, - frame: { - method: "item/started", - params: { - item: answerItem(itemId, ""), - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622440500 + index, - }, - }, - }, - { - type: "emit_inbound", - label: `item/agentMessage/delta/${itemId}`, - frame: { - method: "item/agentMessage/delta", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId, - delta: "CODEX_RECOVERY_OK", - }, - }, - }, - ], - ), - { - type: "emit_inbound", - label: "item/completed/answer-overlap-original", - afterMs: 100, - frame: { - method: "item/completed", - params: { - item: answerItem("answer-overlap-original", "CODEX_RECOVERY_OK"), - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/answer-overlap-duplicate", - frame: { - method: "item/completed", - params: { - item: answerItem("answer-overlap-duplicate", "CODEX_RECOVERY_OK"), - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622441001, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-overlapping-duplicate-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* Effect.yieldNow; - yield* TestClock.adjust("50 millis"); - yield* Effect.yieldNow; - - assert.equal(new Set(assistantTurnItems(harness.events).map((item) => item.id)).size, 1); - - yield* TestClock.adjust("50 millis"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal( - new Set(assistantMessages(harness.events).map((event) => event.message.id)).size, - 1, - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves a sole empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-sole-empty-final", [ - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-sole-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - [""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a second empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-duplicate-empty-final", [ - { id: "answer-empty-original", text: "" }, - { id: "answer-empty-duplicate", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-duplicate-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - [""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("preserves an empty final answer when only commentary preceded it", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-commentary-then-empty-final", [ - { id: "answer-commentary", text: "Working on it.", phase: "commentary" }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-commentary-then-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["Working on it.", ""], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses an empty final answer after a non-empty unknown-phase answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-unknown-non-empty-then-empty-final", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK", phase: null }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-unknown-non-empty-then-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("suppresses a trailing empty answer with an omitted phase", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-final-then-empty-unknown", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - { id: "answer-empty", text: "", omitPhase: true }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-final-then-empty-unknown"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect("keeps a later non-empty final answer after an initial empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-empty-then-non-empty-final", [ - { id: "answer-empty", text: "" }, - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-empty-then-non-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["", "CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const BG_SCENARIO = "codex-bg-exec-wake"; - const BG_NATIVE_THREAD = "native-codex-bg-thread"; - const BG_NATIVE_TURN = "native-codex-bg-turn"; - const BG_COMMAND_ITEM = "call-codex-bg-command"; - const BG_COMMAND = "sleep 20 && echo CODEX_BG_WAKE_DONE"; - const BG_PROMPT = "Start the sleep in the background and reply STARTED."; - - const backgroundCommandItem = (status: "inProgress" | "completed"): Record => ({ - type: "commandExecution", - id: BG_COMMAND_ITEM, - command: BG_COMMAND, - cwd: "/workspace", - processId: "4242", - source: "unifiedExecStartup", - status, - commandActions: [{ type: "unknown", command: BG_COMMAND }], - aggregatedOutput: status === "completed" ? "CODEX_BG_WAKE_DONE\n" : null, - exitCode: status === "completed" ? 0 : null, - durationMs: status === "completed" ? 25_000 : null, - }); - - const backgroundExecTranscript = makeCodexReplayTranscript({ - scenario: BG_SCENARIO, - entries: [ - ...codexReplayPreamble({ - nativeThreadId: BG_NATIVE_THREAD, - nativeTurnId: BG_NATIVE_TURN, - prompt: BG_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: backgroundCommandItem("inProgress"), - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/root-answer", - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: "root-answer-bg", - text: "STARTED", - phase: "final_answer", - memoryCitation: null, - }, - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: BG_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/command-late", - afterMs: 30_000, - frame: { - method: "item/completed", - params: { - item: backgroundCommandItem("completed"), - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - completedAtMs: 1782622465500, - }, - }, - }, - ], - }); - - it.effect( - "projects a post-settle background command completion and requests a continuation", - () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(backgroundExecTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-wake"), - text: BG_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.isTrue( - yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), - ); - assert.lengthOf(harness.continuationRequests, 0); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); - const request = harness.continuationRequests[0]; - assert.equal(request?.threadId, harness.threadId); - assert.equal(request?.providerThreadId, harness.providerThread.id); - assert.equal(request?.driver, CodexAdapterV2.CODEX_DRIVER_KIND); - assert.deepEqual(request?.notification, { - source: { kind: "command" }, - outcome: "completed", - summary: `Command "${BG_COMMAND}" finished (exit 0)`, - detail: BG_COMMAND, - }); - assert.equal( - request?.detail, - `Background command completed (exit 0): ${BG_COMMAND}\n\n` + - "Output tail:\nCODEX_BG_WAKE_DONE", - ); - - const lateCommandUpdateIndex = () => - harness.events.findIndex( - (event, index) => - index > terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - event.turnItem.output === "CODEX_BG_WAKE_DONE\n" && - event.turnItem.exitCode === 0, - ); - yield* awaitUntil( - () => lateCommandUpdateIndex() > terminalIndex, - "post-settle command projection", - ); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - it.effect.each([false, true])( - "keeps commands interrupted after native thread closure (persistent tool: %s)", - (retainContext) => - Effect.gen(function* () { - const notification = (method: string, params: unknown) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params }, - }); - const monitor = (status: "inProgress" | "completed") => - notification(status === "inProgress" ? "item/started" : "item/completed", { - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - item: { - type: "dynamicToolCall", - id: "persistent-monitor", - namespace: "grok", - tool: "monitor", - status, - arguments: { persistent: true, command: "tail -f" }, - }, - }); - // Replies fence notification handling on the same protocol reader. - const unsubscribe = (id: number, notifications: ReturnType[] = []) => [ - { - type: "expect_outbound" as const, - label: `unsubscribe-${id}`, - frame: { id, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, - }, - ...notifications, - { - type: "emit_inbound" as const, - label: `unsubscribe-result-${id}`, - frame: { id, result: { status: "notSubscribed" } }, - }, - ]; - const closed = notification("thread/closed", { threadId: BG_NATIVE_THREAD }); - const transcript = makeCodexReplayTranscript({ - scenario: `codex-background-thread-closed-${retainContext}`, - entries: [ - ...backgroundExecTranscript.entries.slice(0, -2), - ...(retainContext ? [monitor("inProgress")] : []), - backgroundExecTranscript.entries.at(-2)!, - ...unsubscribe(4), - ...unsubscribe(5, [notification("thread/closed", { threadId: "unrelated-thread" })]), - ...unsubscribe(6, [closed]), - ...unsubscribe(7, [ - closed, - notification("item/completed", { - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - item: backgroundCommandItem("completed"), - }), - ...(retainContext ? [monitor("completed")] : []), - ]), - ], - }); - const interrupted = yield* Deferred.make(); - const monitorCompleted = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => { - if (event.type !== "turn_item.updated") return Effect.void; - if ( - event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM && - event.turnItem.status === "interrupted" - ) { - return Deferred.succeed(interrupted, undefined); - } - return event.turnItem.nativeItemRef?.nativeId === "persistent-monitor" && - event.turnItem.status === "completed" - ? Deferred.succeed(monitorCompleted, undefined) - : Effect.void; - }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-thread-closed"), - text: BG_PROMPT, - }), - ); - yield* harness.firstTerminal; - const unload = harness.runtime.unloadThread!({ providerThread: harness.providerThread }); - yield* unload; - assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* unload; - assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* unload; - assert.equal(yield* harness.hasPendingBackgroundWork, retainContext); - yield* Deferred.await(interrupted); - yield* unload; - if (retainContext) yield* Deferred.await(monitorCompleted); - assert.deepEqual( - { - items: harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM - ? [event.turnItem.status] - : [], - ), - nodes: harness.events.flatMap((event) => - event.type === "node.updated" && - event.node.nativeItemRef?.nativeId === BG_COMMAND_ITEM - ? [event.node.status] - : [], - ), - roots: harness.terminalEvents().map((event) => event.status), - continuations: harness.continuationRequests.length, - }, - { - items: ["running", "interrupted"], - nodes: ["running", "interrupted"], - roots: ["completed"], - continuations: 0, - }, - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.isFalse( - yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), - ); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; - const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { - const stillRunning = terminated === "still_running"; - return makeCodexReplayTranscript({ - scenario: `codex-bg-stop-${terminated}`, - entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { - type: "expect_outbound", - label: "terminate-background-command", - frame: { - id: 4, - method: "thread/backgroundTerminals/terminate", - params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, - }, - }, - { - type: "emit_inbound", - label: "terminate-background-command", - frame: - terminated === "thread_unloaded" - ? { - id: 4, - error: { code: -32600, message: `thread not found: ${BG_NATIVE_THREAD}` }, - } - : { id: 4, result: { terminated: terminated === true } }, - }, - ...(terminated === false || stillRunning - ? [ - { - type: "expect_outbound" as const, - frame: { - id: 5, - method: "thread/backgroundTerminals/list", - params: { threadId: BG_NATIVE_THREAD }, - }, - }, - { - type: "emit_inbound" as const, - frame: { - id: 5, - result: { data: stillRunning ? [{ processId: "4242" }] : [], nextCursor: null }, - }, - }, - ] - : []), - ...(stillRunning - ? [ - { - type: "expect_outbound" as const, - frame: { - id: 6, - method: "thread/backgroundTerminals/terminate", - params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, - }, - }, - { - type: "emit_inbound" as const, - frame: { id: 6, result: { terminated: false } }, - }, - { - type: "expect_outbound" as const, - frame: { - id: 7, - method: "thread/backgroundTerminals/list", - params: { threadId: BG_NATIVE_THREAD }, - }, - }, - { - type: "emit_inbound" as const, - frame: { id: 7, result: { data: [{ processId: "4242" }], nextCursor: null } }, - }, - { - type: "expect_outbound" as const, - frame: { - id: 8, - method: "thread/backgroundTerminals/terminate", - params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, - }, - }, - { - type: "emit_inbound" as const, - frame: { id: 8, result: { terminated: true } }, - }, - ] - : []), - backgroundExecTranscript.entries.at(-1)!, - ], - }); - }; - - it.effect.each(backgroundStopCases)( - "stops a command after root completion when termination returns %s", - (terminated) => { - const stillRunning = terminated === "still_running"; - const transcript = makeBackgroundStopTranscript(terminated); - return Effect.scoped( - Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? Deferred.succeed(stopped, undefined) - : Effect.void, - ); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-stop"), - text: BG_PROMPT, - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]!; - assert.equal(terminal.status, "completed"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.isFalse( - yield* harness.runtime.hasPendingBackgroundWorkForThread!({ - ...harness.providerThread, - id: ProviderThreadId.make("unrelated-provider-thread"), - }), - ); - if (stillRunning) { - const failed = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: terminal.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.exit); - assert.equal(failed._tag, "Failure"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - } - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: stillRunning - ? ProviderTurnId.make("later-completed-turn") - : terminal.providerTurnId, - requestRuntimeRestart: true, - }); - yield* Deferred.await(stopped); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - }, - ); - it.effect("stops a completed run's background command without a client-supplied run", () => { - const transcript = makeBackgroundStopTranscript(true); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(transcript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), - ), - ); - const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - assert.equal( - Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), - 0, - ); - assert.equal( - Number( - yield* spawner.exitCode( - ChildProcess.make( - "git", - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - { cwd }, - ), - ), - ), - 0, - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop"), - threadId, - projectId: ProjectId.make("project:background-stop"), - title: "Background stop", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const waiting = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop"), - threadId, - messageId: MessageId.make("message:background-stop"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - assert.isNull((yield* Ref.get(replayDriver.state)).failure); - yield* Fiber.join(waiting); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.at(-1)!; - assert.equal(run.status, "completed"); - assert.equal( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, - 1, - ); - const stopped = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "command_execution" && - event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: CommandId.make("stop-background-command"), - threadId, - }); - yield* worker.drain(); - yield* Fiber.join(stopped); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "completed", - ); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - }); - - it.effect("Stop ends a background command no Codex process tracks any more", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stale-workspace-" }); - const staleTranscript = makeCodexReplayTranscript({ - scenario: "codex-bg-stop-untracked", - entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { type: "runtime_exit", status: "success" }, - ], - }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), - ), - ); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - for (const args of [ - ["init", "--quiet"], - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - ]) { - assert.equal(Number(yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }))), 0); - } - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop-untracked"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop-untracked"), - threadId, - projectId: ProjectId.make("project:background-stop-untracked"), - title: "Background stop untracked", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const settled = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "run.updated" && - (event.payload.status === "waiting" || event.payload.status === "completed"), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop-untracked"), - threadId, - messageId: MessageId.make("message:background-stop-untracked"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - yield* Fiber.join(settled); - yield* worker.drain(); - const before = yield* orchestrator.getThreadShell(threadId); - assert.deepEqual( - before?.pendingBackgroundTasks?.map((task) => task.kind), - ["command"], - "the thread still shows the command the gone process never finished", - ); - const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("stop-background-untracked"), - threadId, - runId: run.id, - holdQueue: true, - }); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual( - projection.turnItems.flatMap((item) => - item.type === "command_execution" ? [item.status] : [], - ), - ["interrupted"], - ); - assert.equal(projection.runs.at(-1)?.status, "completed"); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ transcript: localTranscript }), - { runEffectWorker: false }, - ), - ), - ); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); - - const PRE_SETTLE_SCENARIO = "codex-bg-exec-pre-settle"; - const PRE_SETTLE_NATIVE_THREAD = "native-codex-pre-settle-thread"; - const PRE_SETTLE_NATIVE_TURN = "native-codex-pre-settle-turn"; - - const preSettleTranscript = makeCodexReplayTranscript({ - scenario: PRE_SETTLE_SCENARIO, - entries: [ - ...codexReplayPreamble({ - nativeThreadId: PRE_SETTLE_NATIVE_THREAD, - nativeTurnId: PRE_SETTLE_NATIVE_TURN, - prompt: BG_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: backgroundCommandItem("inProgress"), - threadId: PRE_SETTLE_NATIVE_THREAD, - turnId: PRE_SETTLE_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/command-pre-settle", - frame: { - method: "item/completed", - params: { - item: backgroundCommandItem("completed"), - threadId: PRE_SETTLE_NATIVE_THREAD, - turnId: PRE_SETTLE_NATIVE_TURN, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/root-answer", - frame: { - method: "item/completed", - params: { - item: { - type: "agentMessage", - id: "root-answer-pre-settle", - text: "DONE", - phase: "final_answer", - memoryCitation: null, - }, - threadId: PRE_SETTLE_NATIVE_THREAD, - turnId: PRE_SETTLE_NATIVE_TURN, - completedAtMs: 1782622441500, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: PRE_SETTLE_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: PRE_SETTLE_NATIVE_TURN, status: "completed" }), - }, - }, - }, - ], - }); - - it.effect("does not request a continuation for a command that completes before settle", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(preSettleTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-pre-settle"), - text: BG_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "root turn terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "completed" && - event.turnItem.exitCode === 0, + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + const events: Array = []; + const firstTerminal = yield* Deferred.make(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.terminal" + ? Deferred.succeed(firstTerminal, undefined) + : Effect.void, ), - "pre-settle command projection", - ); - - yield* TestClock.adjust("30 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; - } - assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Effect.andThen(changes.notify), + Effect.andThen(onEvent(event)), + ), ), - ), - ), - ); - - const INTERRUPT_SCENARIO = "codex-interrupt-mid-command"; - const INTERRUPT_NATIVE_THREAD = "native-codex-interrupt-thread"; - const INTERRUPT_NATIVE_TURN = "native-codex-interrupt-turn"; - const INTERRUPT_COMMAND_ITEM = "exec-codex-interrupt-command"; - const INTERRUPT_COMMAND_ITEM_TWO = "exec-codex-interrupt-command-two"; - const INTERRUPT_CHILD_COMMAND_ITEM = "exec-codex-interrupt-child-command"; - const INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM = "exec-codex-interrupt-child-timeout-boundary"; - const INTERRUPT_CHILD_NATIVE_THREAD = "native-codex-interrupt-child-thread"; - const INTERRUPT_CHILD_NATIVE_TURN = "native-codex-interrupt-child-turn"; - const INTERRUPT_LATE_CHILD_NATIVE_TURN = "native-codex-interrupt-late-child-turn"; - const INTERRUPT_LATE_CHILD_2_NATIVE_TURN = "native-codex-interrupt-late-child-2-turn"; - const INTERRUPT_TIMEOUT_BOUNDARY_ITEM = "exec-codex-interrupt-timeout-boundary"; - const INTERRUPT_TIMEOUT_LATE_ITEM = "exec-codex-interrupt-timeout-late"; - const INTERRUPT_COMMAND = "bash -c 'sleep 30; echo SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE'"; - const INTERRUPT_COMMAND_TWO = "bash -c 'sleep 20; echo SECOND_COMMAND'"; - const INTERRUPT_PROMPT = "Run a long foreground command and wait until interrupted."; - - const interruptCommandItem = (status: "inProgress" | "completed"): Record => ({ - type: "commandExecution", - id: INTERRUPT_COMMAND_ITEM, - command: INTERRUPT_COMMAND, - cwd: "/workspace", - processId: "57680", - source: "unifiedExecStartup", - status, - commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND }], - aggregatedOutput: status === "completed" ? "SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE\n" : null, - exitCode: status === "completed" ? 0 : null, - durationMs: status === "completed" ? 30_000 : null, - }); - - const interruptMidCommandTranscript = makeCodexReplayTranscript({ - scenario: INTERRUPT_SCENARIO, - entries: [ - ...codexReplayPreamble({ - nativeThreadId: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_NATIVE_TURN, - prompt: INTERRUPT_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: interruptCommandItem("inProgress"), - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt", - frame: { id: 4, result: {} }, - }, - { - type: "emit_inbound", - label: "item/started/command-two-after-interrupt-response", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_COMMAND_ITEM_TWO, - command: INTERRUPT_COMMAND_TWO, - processId: "57681", - commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440600, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_NATIVE_TURN, - status: "interrupted", - }), - }, - }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one", - frame: { - id: 5, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one", - frame: { id: 5, result: { terminated: false } }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/list/after-false", - frame: { - id: 6, - method: "thread/backgroundTerminals/list", - params: { threadId: INTERRUPT_NATIVE_THREAD }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/list/after-false", - frame: { id: 6, result: { data: [], nextCursor: null } }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/two", - frame: { - id: 7, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/two", - frame: { id: 7, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "item/completed/command-late", - afterMs: 30_000, - frame: { - method: "item/completed", - params: { - item: interruptCommandItem("completed"), - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - completedAtMs: 1782622465500, - }, - }, - }, - ], - }); + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Codex adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const terminalEvents = () => + events.filter( + ( + event, + ): event is Extract => + event.type === "turn.terminal", + ); + const subagentUpdates = () => + events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", + ); + return { + runtime, + providerThread, + threadId, + serverConfig, + events, + continuationRequests, + terminalEvents, + subagentUpdates, + hasPendingBackgroundWork, + firstTerminal: Deferred.await(firstTerminal), + until: changes.until, + awaitTerminal: (count: number) => changes.until(() => terminalEvents().length >= count), + }; + }); - it.effect("contains commands that start before and after the interrupt response", () => - Effect.scoped( + it.effect.each(["supported", "unsupported", "invalid"] as const)( + "delivers native history with %s app-server protocol", + (response) => Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); - const now = yield* DateTime.now; - + const nativeThreadId = `inject-${response}`; + const prompt = "Only the current request"; + const history: ProviderAdapter.ProviderAdapterV2HistoricalContext = { + context: "Historical conversation", + messages: (["user", "assistant"] as const).map((role) => ({ + role, + text: + role === "user" + ? "Original request\n" + "界".repeat(300) + : "Partial interrupted work", + threadId: ThreadId.make("source"), + runId: RunId.make("source-run"), + itemId: TurnItemId.make(`source-${role}`), + providerThreadId: null, + kind: `${role}_message`, + status: "interrupted", + })), + }; + const items = historyResponseItems(history.messages, history.context); + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "current-turn", + prompt, + }); + const transcript = makeCodexReplayTranscript({ + scenario: `inject-${response}`, + entries: [ + ...preamble.slice(0, -3), + { + type: "expect_outbound", + label: "inject", + frame: { + id: 3, + method: "thread/inject_items", + params: { threadId: nativeThreadId, items }, + }, + }, + { + type: "emit_inbound", + label: "inject-result", + frame: + response === "supported" + ? { id: 3, result: {} } + : { + id: 3, + error: { + code: response === "unsupported" ? -32601 : -32602, + message: "Injection rejected", + }, + }, + }, + ...(response === "invalid" + ? [] + : preamble + .slice(-3) + .map((entry) => + "frame" in entry && Predicate.isObject(entry.frame) && "id" in entry.frame + ? { ...entry, frame: { ...entry.frame, id: 4 } } + : entry, + )), + ], + }); + const requests: string[] = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + requests.push(method); + }), + ); + const injection = yield* harness.runtime.injectHistory!({ + providerThread: harness.providerThread, + ...history, + }).pipe(Effect.result); + if (response === "invalid") { + assert.equal(injection._tag, "Failure"); + if (injection._tag === "Failure") { + assert.equal(injection.failure._tag, "ProviderAdapterProtocolError"); + assert.propertyVal(injection.failure.cause, "code", -32602); + assert.notProperty(injection.failure, "payload"); + } + assert.notInclude(requests, "turn/start"); + return; + } + assert.equal(injection._tag, "Success"); + if (injection._tag === "Success") assert.equal(injection.success, response === "supported"); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), - text: INTERRUPT_PROMPT, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("inject-attempt"), + text: prompt, }), ); + assert.equal(requests.filter((method) => method === "turn/start").length, 1); + assert.isBelow(requests.indexOf("thread/inject_items"), requests.indexOf("turn/start")); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ), - "running command item", - ); - - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated", - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - assert.isAtLeast(terminalIndex, 0); - - let lastCommandBeforeTerminal: - | Extract - | undefined; - for (let index = 0; index < terminalIndex; index++) { - const event = harness.events[index]; - if (event?.type === "turn_item.updated" && event.turnItem.type === "command_execution") { - lastCommandBeforeTerminal = event; - } - } - assert.isDefined(lastCommandBeforeTerminal); - assert.equal(lastCommandBeforeTerminal.turnItem.status, "interrupted"); - assert.isNotNull(lastCommandBeforeTerminal.turnItem.completedAt); - - const interruptedCommandsBeforeTerminal = harness.events - .slice(0, terminalIndex) - .flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? [event.turnItem.input] - : [], - ) - .sort(); - assert.deepEqual( - interruptedCommandsBeforeTerminal, - [INTERRUPT_COMMAND, INTERRUPT_COMMAND_TWO].sort(), - ); - - const interruptedCommandIndex = harness.events.findIndex( - (event, index) => - index < terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted", - ); - assert.isAbove( - terminalIndex, - interruptedCommandIndex, - "command terminalization must precede turn.terminal", - ); - - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - - yield* TestClock.adjust("30 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; - } - assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); - - const postTerminalCommandUpdates = harness.events.filter( - (event, index) => - index > terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution", - ); - assert.lengthOf( - postTerminalCommandUpdates, - 0, - "late item/completed after interrupt must not project", - ); + it.effect("adds the session MCP server without replacing launch-argument servers", () => + Effect.gen(function* () { + const scenario = "launch-mcp-servers"; + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-codex-mcp"), + threadId: ThreadId.make(`thread-${scenario}`), + providerSessionId: `provider-session-${scenario}`, + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-codex-token", + browserToolsAvailable: true, + }); + const requests: Array = []; + yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario, + entries: codexReplayPreamble({ + nativeThreadId: "launch-mcp-thread", + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5), + }), + () => Effect.void, + (method, params) => + Effect.sync(() => { + if (method === "thread/start") requests.push(params); + }), + ); + assert.deepEqual(requests, [ + { + cwd: "/workspace", + model: "gpt-5.4", + config: { + "tools.update_plan.enabled": true, + mcp_optional_startup_grace_ms: 10_000, + "mcp_servers.t3-code": { + url: "http://127.0.0.1:43123/mcp", + tool_timeout_sec: 3_900, + http_headers: { Authorization: "Bearer secret-codex-token" }, + }, + }, + }, + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); - const commandUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + // regression: #15839 launch-arg MCP servers survive the thread's t3-code server + it.effect("replays historical and dotted MCP config while enforcing other thread config", () => + Effect.gen(function* () { + const recordedServer = { + url: "http://127.0.0.1:41000/mcp", + http_headers: { Authorization: "Bearer recorded-token" }, + }; + const currentConfig = { + "tools.update_plan.enabled": true, + "mcp_servers.t3-code": { + url: "http://127.0.0.1:42000/mcp", + http_headers: { Authorization: "Bearer current-token" }, + }, + "mcp_servers.extra.command": "cat", + }; + for (const recordedMcp of [ + { mcp_servers: { "t3-code": recordedServer } }, + { "mcp_servers.t3-code": recordedServer, "mcp_servers.extra.command": "echo" }, + ]) { + const entries = codexReplayPreamble({ + nativeThreadId: "replayed-mcp-thread", + nativeTurnId: "unused", + prompt: "unused", + }) + .slice(3, 5) + .map((entry) => withReplayRequestId(entry, 1)); + entries[0] = { + type: "expect_outbound", + frame: { + id: 1, + method: "thread/start", + params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, + }, + }; + entries.push({ + ...entries[0], + frame: { + id: 2, + method: "thread/start", + params: { config: { "tools.update_plan.enabled": true, ...recordedMcp } }, + }, + }); + const driver = yield* CodexReplay.makeReplayDriver( + makeCodexReplayTranscript({ + scenario: "mcp-config-normalization", + entries, + }), ); - assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); - assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), + yield* Effect.gen(function* () { + const client = yield* CodexClient.CodexAppServerClient; + const response = yield* client.request("thread/start", { config: currentConfig }); + assert.equal(response.thread.id, "replayed-mcp-thread"); + assert.isNull((yield* Ref.get(driver.state)).failure); + yield* client + .request("thread/start", { + config: { ...currentConfig, "tools.update_plan.enabled": false }, + }) + .pipe(Effect.flip); + assert.instanceOf( + (yield* Ref.get(driver.state)).failure, + CodexReplay.CodexAppServerReplayFrameMismatchError, + ); + }).pipe(Effect.provide(CodexReplay.layerReplayWithDriver(driver))); + } + }).pipe(Effect.scoped), ); - const interruptSubagentCommandTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-subagent-command", - entries: [ - ...codexReplayPreamble({ - nativeThreadId: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_NATIVE_TURN, - prompt: INTERRUPT_PROMPT, - }), - { - type: "emit_inbound", - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - item: { - type: "subAgentActivity", - id: "call-codex-interrupt-subagent", - kind: "started", - agentThreadId: INTERRUPT_CHILD_NATIVE_THREAD, - agentPath: "/root/stop_hold", + it.effect("waits for native start before interrupting an acknowledged queued turn", () => + Effect.gen(function* () { + const nativeThreadId = "early-stop-thread"; + const nativeTurnId = "early-stop-turn"; + const prompt = "Run a command."; + const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }); + const transcript = makeCodexReplayTranscript({ + scenario: "early-stop-await-native-start", + entries: [ + ...preamble.slice(0, -2), + { + type: "emit_inbound", + label: "turn/start/queued", + frame: { + id: 3, + result: { + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + startedAt: null, + }, + }, }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - completedAtMs: 1782622441000, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_CHILD_NATIVE_TURN, - status: "inProgress", - }), }, - }, - }, - { - type: "emit_inbound", - label: "item/started/child-command", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_CHILD_COMMAND_ITEM, - processId: "57682", + { + type: "emit_inbound", + label: "turn/started", + afterMs: 1000, + frame: { + method: "turn/started", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + }, }, - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - startedAtMs: 1782622441500, - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/root", - frame: { - id: 4, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/root", - frame: { id: 4, result: {} }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/child", - frame: { - id: 5, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/child", - frame: { id: 5, result: {} }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/child", - frame: { - id: 6, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_CHILD_NATIVE_THREAD, processId: "57682" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/child", - frame: { id: 6, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "turn/completed/root", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed/child-completed-race", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_CHILD_NATIVE_TURN, - status: "completed", - }), + { + type: "expect_outbound", + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - const assertChildProviderTerminalBeforeRoot = ( - events: ReadonlyArray, - rootThreadId: ThreadId, - ) => { - const terminalIndex = events.findIndex((event) => event.type === "turn.terminal"); - const childProviderTurnIndex = events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.threadId !== rootThreadId && - event.providerTurn.status === "interrupted", - ); - const childProviderThreadIndex = events.findIndex( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.appThreadId !== rootThreadId && - event.providerThread.status === "idle", - ); - assert.isAtLeast(childProviderTurnIndex, 0, "child provider turn must terminalize"); - assert.isAtLeast(childProviderThreadIndex, 0, "child provider thread must become idle"); - assert.isAbove( - terminalIndex, - childProviderTurnIndex, - "child provider turn must terminalize before the root run", - ); - assert.isAbove( - terminalIndex, - childProviderThreadIndex, - "child provider thread must become idle before the root run", - ); - }; - - it.effect("contains descendant commands and keeps Stop authoritative", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), - text: INTERRUPT_PROMPT, + { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), + }, + }, + }, + ], + }); + let interruptSent = false; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method) => + Effect.sync(() => { + if (method === "turn/interrupt") interruptSent = true; }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - "running child command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - yield* harness.runtime.interruptTurn({ + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, providerThread: harness.providerThread, - providerTurnId, - }); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const childCommandUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, - ); - assert.equal(childCommandUpdates.at(-1)?.turnItem.status, "interrupted"); - assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), + now: yield* DateTime.now, + attemptId: RunAttemptId.make("early-stop-attempt"), + text: prompt, + }), + ); + const providerTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + nativeTurnId, + }); + const interrupt = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("500 millis"); + assert.isFalse(interruptSent, "Stop must not reach Codex before native turn/started"); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(interrupt); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + assert.lengthOf(harness.terminalEvents(), 1); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), ); - const childInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/child", - ); - const rootInterruptResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/interrupt/root", - ); - const interruptSubagentRequestFailureTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-subagent-request-failure", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, rootInterruptResponseIndex + 1), - { - type: "emit_inbound", - label: "turn/completed/root-before-child-interrupt-failure", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "interrupted" }), - }, - }, - }, - ...interruptSubagentCommandTranscript.entries.slice( - rootInterruptResponseIndex + 1, - childInterruptResponseIndex, - ), - { - type: "emit_inbound", - label: "turn/interrupt/child", - frame: { - id: 5, - error: { code: -32_000, message: "child interrupt request failed" }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); + const assistantMessages = (events: ReadonlyArray) => + events.filter( + ( + event, + ): event is Extract => + event.type === "message.updated" && event.message.role === "assistant", + ); - it.effect("terminalizes descendants before the root when an interrupt request fails", () => + it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentRequestFailureTranscript); - const now = yield* DateTime.now; - + const nativeThreadId = "async-question-thread"; + const nativeTurnId = "async-question-turn"; + const usage = { + totalTokens: 15, + inputTokens: 10, + cachedInputTokens: 2, + outputTokens: 5, + reasoningOutputTokens: 1, + }; + const transcript = makeCodexReplayTranscript({ + scenario: "async-question-and-billed-usage", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Continue while I decide.", + }), + { + type: "emit_inbound", + label: "question", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "agentMessage", + id: "async-question-item", + text: "Which branch?", + delivery: "async", + questions: [{ title: "Which branch?", options: ["main", "dev"] }], + }, + }, + }, + }, + { + type: "emit_inbound", + label: "usage", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + }, + }, + }, + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-request-failure"), - text: INTERRUPT_PROMPT, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("async-question-attempt"), + text: "Continue while I decide.", }), ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - "running child command item", + yield* harness.firstTerminal; + const requests = harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptExit = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit); - - assert.equal(interruptExit._tag, "Failure"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(requests, 1); + assert.equal(requests[0]?.status, "pending"); + assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); + const questionItem = harness.events.find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + assert.equal(questionItem?.type, "turn_item.updated"); + if ( + questionItem?.type === "turn_item.updated" && + questionItem.turnItem.type === "user_input_request" + ) { + assert.deepEqual( + questionItem.turnItem.questions[0]?.options.map((option) => option.label), + ["main", "dev"], + ); + assert.equal(questionItem.turnItem.responseMode, "message"); + } + const questionNode = harness.events.find( + (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, + ); + assert.equal( + questionNode?.type === "node.updated" && questionNode.node.countsForRun, + false, + ); + const contextReport = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, + ); + assert.equal(contextReport?.type, "provider_turn.updated"); + if (contextReport?.type === "provider_turn.updated") { + assert.equal( + contextReport.providerTurn.runAttemptId, + RunAttemptId.make("async-question-attempt"), + ); + assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); + assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); + assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); + } + const completed = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.equal(completed?.type, "provider_turn.updated"); + if (completed?.type === "provider_turn.updated") { + assert.deepEqual(completed.providerTurn.turnTokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, + inputTokens: 10, + cachedInputTokens: 2, + outputTokens: 5, + reasoningTokens: 1, + }); + } + assert.isEmpty(assistantMessages(harness.events)); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6247,171 +1481,302 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const childTurnStartedIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/started/child", - ); - const failedRootLiveChildTranscript = makeCodexReplayTranscript({ - scenario: "codex-failed-root-live-subagent", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, childTurnStartedIndex + 1), - { - type: "emit_inbound", - label: "turn/completed/root-failed", - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: { - ...makeCodexReplayTurn({ id: INTERRUPT_NATIVE_TURN, status: "failed" }), - error: { - message: "unsupported websocket request type: response.interrupt", - codexErrorInfo: "other", - additionalDetails: null, + it.effect("stops asking for commands the user allowed for the session", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "session-approval-thread"; + const turnIds = [ + "session-approval-turn-1", + "session-approval-turn-2", + "session-approval-turn-3", + ]; + const turnPolicies = { + "approval-required": { + approvalPolicy: "untrusted", + approvalsReviewer: "user", + sandboxPolicy: { type: "readOnly" }, + }, + "full-access": { + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + } as const; + const turnModes = ["approval-required", "full-access", "approval-required"] as const; + const turnText = (index: number) => `Review round ${index + 1}.`; + const startTurn = (index: number): ReadonlyArray => [ + { + type: "expect_outbound", + label: `turn ${index + 1} turn/start`, + frame: { + id: 3 + index, + method: "turn/start", + params: { + threadId: nativeThreadId, + input: [{ type: "text", text: turnText(index) }], + cwd: "/workspace", + model: "gpt-5.4", + ...turnPolicies[turnModes[index]!], + summary: "detailed", }, }, }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed/child", - afterMs: 30_000, - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: INTERRUPT_CHILD_NATIVE_TURN, status: "completed" }), + { + type: "emit_inbound", + label: `turn ${index + 1} turn/start`, + frame: { + id: 3 + index, + result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, + }, }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("settles a failed root without waiting for subagents that keep running", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(failedRootLiveChildTranscript); - const now = yield* DateTime.now; - const childTurnCompleted = () => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.threadId !== harness.threadId && - event.providerTurn.status === "completed", - ); - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-failed-root-live-subagent"), - text: INTERRUPT_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed root terminal"); - - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - assert.isTrue(terminal?.status === "failed" && terminal.childTurnsContinue === true); - assert.isFalse(childTurnCompleted(), "the subagent is still running"); - - yield* TestClock.adjust("30 seconds"); - yield* awaitUntil(childTurnCompleted, "subagent turn completion"); - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ), - ); - - const childTerminationResponseIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => - entry.type === "emit_inbound" && entry.label === "thread/backgroundTerminals/terminate/child", - ); - const interruptSubagentTimeoutTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-subagent-timeout", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, childTerminationResponseIndex + 1), - { - type: "emit_inbound", - label: "item/started/child-timeout-boundary", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM, - processId: null, + turnEvent("turn/started", turnIds[index]!), + ]; + const approval = (input: { + readonly id: number; + readonly turnId: string; + readonly kind: "commandExecution" | "fileChange"; + readonly itemId: string; + readonly decision: string; + }): ReadonlyArray => [ + { + type: "emit_inbound", + label: `${input.itemId} approval`, + frame: { + id: input.id, + method: `item/${input.kind}/requestApproval`, + params: { + threadId: nativeThreadId, + turnId: input.turnId, + itemId: input.itemId, + startedAtMs: 1782622441000, + ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), + }, }, - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_CHILD_NATIVE_TURN, - startedAtMs: 1782622441600, }, - }, - }, - ], - }); + { + type: "expect_outbound", + label: `${input.itemId} decision`, + frame: { id: input.id, result: { decision: input.decision } }, + }, + ]; + const turnEvent = ( + method: "turn/started" | "turn/completed", + turnId: string, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `${turnId} ${method}`, + frame: { + method, + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ + id: turnId, + status: method === "turn/started" ? "inProgress" : "completed", + }), + }, + }, + }); + const [patchRequest, patchDecision] = approval({ + id: 102, + turnId: turnIds[0]!, + kind: "fileChange", + itemId: "first-patch", + decision: "acceptForSession", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-session-approvals", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: turnIds[0]!, + prompt: turnText(0), + }).slice(0, -3), + ...startTurn(0), + ...approval({ + id: 100, + turnId: turnIds[0]!, + kind: "commandExecution", + itemId: "first-command", + decision: "acceptForSession", + }), + patchRequest!, + ...approval({ + id: 101, + turnId: turnIds[0]!, + kind: "commandExecution", + itemId: "second-command", + decision: "acceptForSession", + }), + turnEvent("turn/completed", turnIds[0]!), + ...startTurn(1), + patchDecision!, + turnEvent("turn/completed", turnIds[1]!), + ...startTurn(2), + ...approval({ + id: 103, + turnId: turnIds[2]!, + kind: "commandExecution", + itemId: "third-command", + decision: "accept", + }), + ...approval({ + id: 104, + turnId: turnIds[2]!, + kind: "fileChange", + itemId: "second-patch", + decision: "accept", + }), + turnEvent("turn/completed", turnIds[2]!), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const requests = () => [ + ...new Map( + harness.events.flatMap((event) => + event.type === "runtime_request.updated" + ? [[event.runtimeRequest.id, event.runtimeRequest] as const] + : [], + ), + ).values(), + ]; + const answer = ( + count: number, + kind: "command" | "file-change", + decision: ProviderApprovalDecision, + ) => + Effect.gen(function* () { + yield* harness.until(() => requests().length === count); + const request = requests()[count - 1]!; + assert.equal(request.kind, kind); + yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + }); + + const runTurn = (index: number) => + Effect.gen(function* () { + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), + text: turnText(index), + }), + runOrdinal: index + 1, + providerTurnOrdinal: index + 1, + runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, + }); + }); + + yield* runTurn(0); + yield* answer(1, "command", "acceptForSession"); + yield* harness.until(() => requests().length === 2); + yield* harness.awaitTerminal(1); + yield* runTurn(1); + yield* answer(2, "file-change", "acceptForSession"); + yield* harness.awaitTerminal(2); + yield* runTurn(2); + yield* answer(3, "command", "accept"); + yield* answer(4, "file-change", "accept"); + yield* harness.awaitTerminal(3); + assert.lengthOf(requests(), 4); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); - it.effect("terminalizes timed-out descendants before the root", () => + it.effect("preserves T3 context on the wire and restores it after compaction", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentTimeoutTranscript); - const now = yield* DateTime.now; - + const nativeThreadId = "context-thread"; + const nativeTurnId = "context-turn"; + const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId, + codexInput: [{ type: "text", text: "work" }], + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + modelSelection: CODEX_TEST_MODEL_SELECTION, + hasT3Mcp: true, + }); + assert.include( + params.additionalContext?.t3_code_orchestration_01?.value ?? "", + "delegate_task", + ); + const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); + const transcript = makeCodexReplayTranscript({ + scenario: "restore-context", + entries: [ + ...entries.slice(0, 5), + { + type: "expect_outbound", + label: "context turn", + frame: { id: 3, method: "turn/start", params }, + }, + ...entries.slice(6), + { + type: "emit_inbound", + label: "compacted", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { type: "contextCompaction", id: "compact-context" }, + }, + }, + }, + { + type: "expect_outbound", + label: "restore context", + frame: { + id: 4, + method: "thread/inject_items", + params: { + threadId: nativeThreadId, + items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: `<${key}>${entry.value}` }], + })), + }, + }, + }, + { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, + { + type: "emit_inbound", + label: "done", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test"), + threadId: harness.threadId, + providerSessionId: "context-session", + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer test", + browserToolsAvailable: true, + }); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-timeout"), - text: INTERRUPT_PROMPT, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("context-attempt"), + text: "work", }), ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - "running child command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_TIMEOUT_BOUNDARY_ITEM && - event.turnItem.status === "running", - ), - "child timeout boundary item", - ); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6420,113 +1785,78 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const rootCompletionIndex = interruptSubagentCommandTranscript.entries.findIndex( - (entry) => entry.type === "emit_inbound" && entry.label === "turn/completed/root", - ); - const interruptLateSubagentTurnTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-late-subagent-turn", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, rootCompletionIndex), - { - type: "emit_inbound", - label: "turn/started/late-child", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "inProgress", - }), - }, - }, - }, - ...interruptSubagentCommandTranscript.entries.slice(rootCompletionIndex, -1), - { - type: "expect_outbound", - label: "turn/interrupt/late-child", - frame: { - id: 7, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child", - frame: { id: 7, result: {} }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("interrupts descendants that start after the initial Stop snapshot", () => + it.effect("compacts Codex with the native RPC and completes the compaction turn", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const item = { type: "contextCompaction", id: "compact-item" }; + const transcript = makeCodexReplayTranscript({ + scenario: "native-compaction", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), + { + type: "expect_outbound", + label: "compact", + frame: { + id: 3, + method: "thread/compact/start", + params: { threadId: nativeThreadId }, + }, + }, + { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, + { + type: "emit_inbound", + label: "start", + frame: { + method: "turn/started", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + }, + }, + }, + ...(["item/started", "item/completed"] as const).map((method) => ({ + type: "emit_inbound" as const, + label: method, + frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, + })), + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + assert.isDefined(harness.runtime.compactThread); + yield* harness.runtime.compactThread!( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-late-subagent-turn"), - text: INTERRUPT_PROMPT, - }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - "running child command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, - ), - "late child provider turn", - ); - yield* Fiber.join(interruptFiber); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - const lateChildUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("compact-attempt"), + text: "/compact", + }), ); - assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + yield* harness.firstTerminal; + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + items.map((entry) => entry.status), + ["running", "completed"], + ); + assert.equal(items[0]?.id, items[1]?.id); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6535,167 +1865,187 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptRescanLateSubagentTurnTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-rescan-late-subagent-turn", - entries: [ - ...interruptSubagentCommandTranscript.entries.slice(0, childTerminationResponseIndex + 1), - { - type: "emit_inbound", - label: "turn/started/late-child-1", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_NATIVE_TURN, - status: "inProgress", - }), - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/late-child-1", - frame: { - id: 7, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/started/late-child-2", - frame: { - method: "turn/started", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - status: "inProgress", + it.effect.each(["failed", "interrupted", "Stop"] as const)( + "settles unfinished compaction on %s without changing completed compaction", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const error = { + message: "Remote compaction stream disconnected", + codexErrorInfo: "other", + additionalDetails: null, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `automatic-compaction-${outcome}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...( + [ + { method: "item/started", id: "completed-item", startedAtMs: 1782622441000 }, + { method: "item/completed", id: "completed-item", completedAtMs: 1782622442000 }, + { method: "item/started", id: "unfinished-item", startedAtMs: 1782622443000 }, + ] as const + ).map(({ method, id, ...timestamps }) => ({ + type: "emit_inbound" as const, + label: `${method}/${id}`, + frame: { + method, + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { type: "contextCompaction", id }, + ...timestamps, + }, + }, + })), + ...(outcome === "Stop" + ? [ + { + type: "expect_outbound" as const, + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, + }, + { + type: "emit_inbound" as const, + label: "turn/interrupt/failed", + frame: { id: 4, error: { code: -32_000, message: "Interrupt failed" } }, + }, + ] + : [ + { + type: "emit_inbound" as const, + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: outcome }), + error: outcome === "failed" ? error : null, + }, + }, + }, + }, + ]), + ], + }); + const compactionStarted = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && + event.turnItem.status === "running" && + event.turnItem.providerTurnId !== null + ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) + : Effect.void, + ); + yield* TestClock.setTime(1782622445000); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`compact-${outcome}-attempt`), + text: "Continue.", }), - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child-1", - frame: { id: 7, result: {} }, - }, - { - type: "expect_outbound", - label: "turn/interrupt/late-child-2", - frame: { - id: 8, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_CHILD_NATIVE_THREAD, - turnId: INTERRUPT_LATE_CHILD_2_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt/late-child-2", - frame: { id: 8, result: {} }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); + ); + if (outcome === "Stop") { + const providerTurnId = yield* Deferred.await(compactionStarted); + const interrupt = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.exit); + assert.equal(interrupt._tag, "Failure"); + } + yield* harness.firstTerminal; + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + const completed = items.filter( + (item) => item.nativeItemRef?.nativeId === "completed-item", + ); + const unfinished = items.filter( + (item) => item.nativeItemRef?.nativeId === "unfinished-item", + ); + const status = outcome === "failed" ? "failed" : "interrupted"; + assert.deepEqual( + unfinished.map((item) => item.status), + ["running", status], + ); + assert.equal(unfinished[0]?.id, unfinished[1]?.id); + assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); + assert.deepEqual( + unfinished[1]?.completedAt, + DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), + ); + assert.deepEqual( + completed.map((item) => item.status), + ["running", "completed"], + ); + assert.deepEqual(completed[1]?.startedAt, DateTime.makeUnsafe(1782622441000)); + assert.deepEqual(completed[1]?.completedAt, DateTime.makeUnsafe(1782622442000)); + assert.equal(harness.terminalEvents()[0]?.status, status); + if (outcome === "failed") { + assert.equal(harness.terminalEvents()[0]?.failure?.message, error.message); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); - it.effect("interrupts descendants discovered only by the final interrupt rescan", () => + it.effect("resumes a provider thread without requesting or decoding its history", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptRescanLateSubagentTurnTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-rescan-late-subagent-turn"), - text: INTERRUPT_PROMPT, - }), - ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - "running child command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, - ), - "late child 1 provider turn", - ); - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted root terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + const scenario = "codex-resume-metadata"; + const nativeThreadId = `native-${scenario}-thread`; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5); + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...preamble, + { + type: "expect_outbound", + label: "thread/resume", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "thread/resume", + frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const resumed = yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); - const rootTerminalIndex = harness.events.findIndex( - (event) => event.type === "turn.terminal", - ); - const lateChild1InterruptedIndex = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN && - event.providerTurn.status === "interrupted", - ); - const lateChild2InterruptedIndex = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_2_NATIVE_TURN && - event.providerTurn.status === "interrupted", - ); - assert.isAtLeast( - lateChild1InterruptedIndex, - 0, - "late child 1 must terminalize interrupted", - ); - assert.isAtLeast( - lateChild2InterruptedIndex, - 0, - "late child 2 must terminalize interrupted", - ); - assert.isAbove( - rootTerminalIndex, - lateChild1InterruptedIndex, - "late child 1 must terminalize before the root run", - ); - assert.isAbove( - rootTerminalIndex, - lateChild2InterruptedIndex, - "late child 2 must terminalize before the root run", - ); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6704,267 +2054,126 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptTimeoutTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-timeout", - entries: [ - ...codexReplayPreamble({ - nativeThreadId: INTERRUPT_NATIVE_THREAD, - nativeTurnId: INTERRUPT_NATIVE_TURN, - prompt: INTERRUPT_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: interruptCommandItem("inProgress"), - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "expect_outbound", - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/interrupt", - frame: { id: 4, result: {} }, - }, - { - type: "emit_inbound", - label: "item/started/command-two-after-interrupt-response", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_COMMAND_ITEM_TWO, - command: INTERRUPT_COMMAND_TWO, - processId: "57681", - commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622440600, - }, - }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one", - frame: { - id: 5, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one", - frame: { id: 5, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "item/started/command-at-timeout-boundary", - afterMs: 9_999, - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - command: "echo TIMEOUT_BOUNDARY", - processId: null, - commandActions: [{ type: "unknown", command: "echo TIMEOUT_BOUNDARY" }], - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622450500, - }, - }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/two", - frame: { - id: 6, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57681" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/two", - frame: { id: 6, result: { terminated: true } }, - }, - { - type: "emit_inbound", - label: "turn/completed/late", - afterMs: 20_000, - frame: { - method: "turn/completed", - params: { - threadId: INTERRUPT_NATIVE_THREAD, - turn: makeCodexReplayTurn({ - id: INTERRUPT_NATIVE_TURN, - status: "interrupted", - }), - }, - }, - }, - { - type: "emit_inbound", - label: "item/started/command-after-timeout", - frame: { - method: "item/started", - params: { - item: { - ...interruptCommandItem("inProgress"), - id: INTERRUPT_TIMEOUT_LATE_ITEM, - command: "echo LATE_AFTER_TIMEOUT", - processId: null, - commandActions: [{ type: "unknown", command: "echo LATE_AFTER_TIMEOUT" }], - }, - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - startedAtMs: 1782622470500, - }, - }, - }, - { - type: "emit_inbound", - label: "item/completed/command-late", - frame: { - method: "item/completed", - params: { - item: interruptCommandItem("completed"), - threadId: INTERRUPT_NATIVE_THREAD, - turnId: INTERRUPT_NATIVE_TURN, - completedAtMs: 1782622465500, - }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("bounds interrupt settlement and drops late completion events", () => + // regression: #15389 archived native Codex sessions resume via thread/unarchive + it.effect.each( + ( + [ + ["recovers archived-session errors", "session saved-thread is archived", 0, ""], + ["preserves missing-thread errors", "thread not found", 3, "thread not found"], + [ + "propagates unarchive missing-thread errors", + "session saved-thread is archived", + 4, + "thread not found", + ], + [ + "does not retry archived errors twice", + "session saved-thread is archived", + 5, + "session saved-thread is archived", + ], + ] as const + ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), + )("$name", ({ name, resumeError, failAt, finalError }) => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptTimeoutTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-timeout"), - text: INTERRUPT_PROMPT, - }), - ); - yield* awaitUntil( - () => - harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ).length === 1, - "running command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated", - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }) - .pipe(Effect.forkScoped); - yield* awaitUntil( - () => - harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ).length === 2, - "post-interrupt running command item", - ); - - yield* TestClock.adjust("10 seconds"); - yield* Fiber.join(interruptFiber); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "timeout terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const terminalProviderTurnsBeforeLateEvents = harness.events.filter( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", - ); - assert.lengthOf(terminalProviderTurnsBeforeLateEvents, 1); - - const commandUpdatesBeforeLateEvents = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - const terminalCommands = commandUpdatesBeforeLateEvents.filter( - (event) => - event.turnItem.status === "interrupted" && - event.turnItem.nativeItemRef?.nativeId !== INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - ); - assert.lengthOf(terminalCommands, 2); - const boundaryUpdates = commandUpdatesBeforeLateEvents.filter( - (event) => event.turnItem.nativeItemRef?.nativeId === INTERRUPT_TIMEOUT_BOUNDARY_ITEM, - ); - const lastBoundaryUpdate = boundaryUpdates.at(-1); - assert.isDefined(lastBoundaryUpdate); - assert.equal(lastBoundaryUpdate.turnItem.status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - yield* TestClock.adjust("20 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; + const nativeThreadId = "saved-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume archived thread", + frame: { id: 3, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, + }, + ]; + if (failAt !== 3) { + entries.push( + { + type: "expect_outbound", + label: "unarchive same thread", + frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "unarchive result", + frame: + failAt === 4 + ? { id: 4, error: { code: -32600, message: finalError } } + : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + }, + ); } - assert.lengthOf(harness.terminalEvents(), 1); - assert.lengthOf( - harness.events.filter( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "interrupted", - ), - terminalProviderTurnsBeforeLateEvents.length, - "late completion must not duplicate provider-turn finalization", + if (failAt === 0 || failAt === 5) { + entries.push( + { + type: "expect_outbound", + label: "retry identical resume", + frame: { id: 5, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "retry result", + frame: + failAt === 5 + ? { id: 5, error: { code: -32600, message: finalError } } + : { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, + }, + ); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), ); - assert.lengthOf( - harness.events.filter( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ), - commandUpdatesBeforeLateEvents.length, - "late starts and completions must not project after timeout", + const resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + if (failAt !== 0) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.nestedPropertyVal(error, "cause.errorMessage", finalError); + assert.nestedPropertyVal( + error, + "cause.method", + failAt === 4 ? "thread/unarchive" : "thread/resume", + ); + assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); + return; + } + const resumed = yield* resume; + assert.equal(resumed.id, harness.providerThread.id); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual( + resumed.nativeConversationHeadRef, + harness.providerThread.nativeConversationHeadRef, ); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6973,109 +2182,150 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const interruptTerminationFailureTranscript = makeCodexReplayTranscript({ - scenario: "codex-interrupt-termination-failure", - entries: [ - ...interruptMidCommandTranscript.entries - .filter( - (entry) => entry.type === "runtime_exit" || entry.label !== "item/completed/command-late", - ) - .map((entry) => - entry.type === "emit_inbound" && - entry.label === "thread/backgroundTerminals/list/after-false" - ? { - ...entry, - frame: { - id: 6, - result: { - data: [{ processId: "57680" }], - nextCursor: null, - }, - }, - } - : entry, - ), - { - type: "expect_outbound", - label: "thread/backgroundTerminals/terminate/one-retry", - frame: { - id: 8, - method: "thread/backgroundTerminals/terminate", - params: { threadId: INTERRUPT_NATIVE_THREAD, processId: "57680" }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/terminate/one-retry", - frame: { id: 8, result: { terminated: false } }, - }, - { - type: "expect_outbound", - label: "thread/backgroundTerminals/list/after-false-retry", - frame: { - id: 9, - method: "thread/backgroundTerminals/list", - params: { threadId: INTERRUPT_NATIVE_THREAD }, - }, - }, - { - type: "emit_inbound", - label: "thread/backgroundTerminals/list/after-false-retry", - frame: { - id: 9, - result: { data: [{ processId: "57680" }], nextCursor: null }, - }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - - it.effect("attempts every terminal and cleans up tracking when termination fails", () => + it.effect("resolves retryable app-server errors on resumed provider activity", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptTerminationFailureTranscript); + const scenario = "codex-provider-api-retry"; + const nativeThreadId = `native-${scenario}-thread`; + const nativeTurnId = `native-${scenario}-turn`; + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Open github.com.", + }), + { + type: "emit_inbound", + label: "error/retry", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: true, + error: { + message: "Reconnecting... 2/5", + additionalDetails: "The response stream disconnected.", + codexErrorInfo: { + responseStreamDisconnected: { httpStatusCode: 529 }, + }, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "item/started/after-retry", + frame: { + method: "item/started", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + startedAtMs: 1782622445000, + item: { + type: "commandExecution", + id: "command-after-provider-retry", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: null, + exitCode: null, + durationMs: null, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/after-retry", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + completedAtMs: 1782622445010, + item: { + type: "commandExecution", + id: "command-after-provider-retry", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "completed", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: "/workspace\n", + exitCode: 0, + durationMs: 10, + }, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); const now = yield* DateTime.now; - yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-termination-failure"), - text: INTERRUPT_PROMPT, + attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), + text: "Open github.com.", }), ); - yield* awaitUntil( - () => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ), - "running command item", - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated", - )?.providerTurn.id; - assert.isDefined(providerTurnId); + yield* harness.awaitTerminal(1); - const interruptExit = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }) - .pipe(Effect.exit); + const retryItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.retry !== undefined + ? [event.turnItem] + : [], + ); + assert.lengthOf(retryItems, 2); + assert.equal(retryItems[0]?.status, "running"); + assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); + assert.deepEqual(retryItems[0]?.retry, { + attempt: 2, + maxAttempts: 5, + retryDelayMs: null, + }); + assert.equal(retryItems[1]?.id, retryItems[0]?.id); + assert.equal(retryItems[1]?.status, "completed"); + assert.equal(retryItems[1]?.title, "Provider recovered"); - assert.equal(interruptExit._tag, "Failure"); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "interrupted terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + const recoveredIndex = harness.events.findIndex( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.status === "completed", + ); + const resumedCommandIndex = harness.events.findIndex( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.input === "pwd", + ); + const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); + assert.isAtLeast(recoveredIndex, 0); + assert.isAbove(resumedCommandIndex, recoveredIndex); + assert.isAbove(terminalIndex, resumedCommandIndex); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7084,253 +2334,299 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect.each([ - { - name: "usage", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { name: "rate", code: "rateLimitExceeded", notification: false, expectedClass: "usage_limit" }, - { - name: "ordinary", - code: "contextWindowExceeded", - notification: false, - expectedClass: "provider_error", - }, - { - name: "notification", - code: "usageLimitExceeded", - notification: true, - expectedClass: "usage_limit", - }, - { - name: "replacement", - code: "usageLimitExceeded", - notification: true, - expectedClass: "provider_error", - }, - { - name: "known-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { - name: "late-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { - name: "matching-details", - code: "usageLimitExceeded", - notification: true, - expectedClass: "usage_limit", - }, - { - name: "deferred-reset", - code: "usageLimitExceeded", - notification: false, - expectedClass: "usage_limit", - }, - { name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" }, - { - name: "exhausted-429", - code: "responseTooManyFailedAttempts", - errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 429 } }, - notification: false, - expectedClass: "usage_limit", - }, - { - name: "exhausted-500", - code: "responseTooManyFailedAttempts", - errorInfo: { responseTooManyFailedAttempts: { httpStatusCode: 500 } }, - notification: false, - expectedClass: "provider_error", - }, - ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `native-limit-${scenario.name}`; - const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "Provider stopped this request."; - const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; - const resetAt = "2033-05-19T07:20:00.000Z"; - const snapshot = { - type: "emit_inbound" as const, - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, - }, - }, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-limit-${scenario.name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...(scenario.name === "known-reset" || scenario.name === "deferred-reset" - ? [snapshot] - : []), - ...(scenario.name === "deferred-reset" - ? [ - { - type: "emit_inbound" as const, - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "subAgentActivity", - id: "limit-child-spawn", - kind: "started", - agentThreadId: "native-limit-child", - agentPath: "/root/limit_child", - }, - }, + it.effect.each(["completed", "interrupted"] as const)( + "retains Codex reasoning parts when the turn is %s", + (terminalStatus) => + Effect.scoped( + Effect.gen(function* () { + const scenario = `codex-reasoning-${terminalStatus}`; + const nativeThreadId = `native-${scenario}-thread`; + const nativeTurnId = `native-${scenario}-turn`; + const prompt = "Explain the check."; + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), + ...[ + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, + }, + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 0, delta: "one" }, + }, + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, + }, + { + method: "item/reasoning/textDelta", + params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, + }, + { + method: "item/completed", + params: { + item: { + type: "commandExecution", + id: "after-thought", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "completed", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: "/workspace", + exitCode: 0, + durationMs: 1, }, }, - { - type: "emit_inbound" as const, - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "inProgress", - }), + }, + ...(terminalStatus === "completed" + ? [ + { + method: "item/completed", + params: { + item: { + type: "reasoning", + id: "thought", + summary: ["Final summary one", "Summary two"], + content: ["Raw trace"], + }, + }, }, - }, - }, - ] - : []), - ...(scenario.notification - ? [ - { - type: "emit_inbound" as const, - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: scenario.name === "retry", - error: { - message, - codexErrorInfo: errorInfo, - additionalDetails: - scenario.name === "matching-details" - ? "Detailed provider allowance explanation." - : null, + { + method: "item/completed", + params: { + item: { + type: "reasoning", + id: "completion-only", + summary: ["Completion without deltas"], + content: [], + }, }, }, - }, + { + method: "item/reasoning/textDelta", + params: { + itemId: "delta-only", + contentIndex: 0, + delta: "Retained when completion omits content", + }, + }, + { + method: "item/completed", + params: { + item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, + }, + }, + ] + : []), + ].map((event, index) => ({ + type: "emit_inbound" as const, + label: `reasoning-${index}`, + frame: { + method: event.method, + params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, + }, + })), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-${scenario}`), + text: prompt, + }), + ); + yield* harness.firstTerminal; + const latest = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "reasoning" + ? [[event.turnItem.id, event.turnItem] as const] + : [], + ), + ); + assert.deepEqual( + [...latest.values()].map((item) => item.text), + terminalStatus === "completed" + ? [ + "Final summary one", + "Summary two", + "Raw trace", + "Completion without deltas", + "Retained when completion omits content", ] - : []), - { + : ["Summary one", "Summary two", "Raw trace"], + ); + assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); + assert.isTrue([...latest.values()].every((item) => item.streaming === false)); + assert.isTrue( + [...latest.values()].every( + (item) => item.runId !== null && item.providerTurnId !== null, + ), + ); + assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); + const command = harness.events.find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ); + assert.isDefined(command); + if (command?.type === "turn_item.updated") { + assert.isTrue( + [...latest.values()] + .slice(0, 3) + .every((item) => item.ordinal < command.turnItem.ordinal), + ); + } + assert.deepEqual(assistantMessages(harness.events), []); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + const finalAnswerTranscript = ( + scenario: string, + answers: ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly phase?: "commentary" | "final_answer" | null; + readonly omitPhase?: boolean; + readonly streamed?: boolean; + readonly completionDelayMs?: number; + }>, + ) => { + const nativeThreadId = `native-${scenario}-thread`; + const nativeTurnId = `native-${scenario}-turn`; + const prompt = "Reply with the requested recovery marker."; + return makeCodexReplayTranscript({ + scenario, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), + ...answers.flatMap( + (answer, index): ReadonlyArray => { + const phase = answer.omitPhase + ? {} + : { phase: answer.phase === undefined ? ("final_answer" as const) : answer.phase }; + const completed: CodexReplay.CodexAppServerReplayEntry = { type: "emit_inbound", - label: "turn/completed", + label: `item/completed/${answer.id}`, + ...(answer.completionDelayMs === undefined + ? {} + : { afterMs: answer.completionDelayMs }), frame: { - method: "turn/completed", + method: "item/completed", params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message: scenario.name === "replacement" ? "A different failure." : message, - ...(scenario.notification && scenario.name !== "matching-details" - ? {} - : { codexErrorInfo: errorInfo }), - }, + item: { + type: "agentMessage", + id: answer.id, + text: answer.text, + ...phase, + memoryCitation: null, }, + threadId: nativeThreadId, + turnId: nativeTurnId, + completedAtMs: 1782622441000 + index, }, }, - }, - ...(scenario.name === "late-reset" ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: 100, - resetsAt: 2000200000, - windowDurationMins: 300, - }, - }, - }, + }; + if (!answer.streamed) { + return [completed]; + } + return [ + { + type: "emit_inbound", + label: `item/started/${answer.id}`, + frame: { + method: "item/started", + params: { + item: { + type: "agentMessage", + id: answer.id, + text: "", + ...phase, + memoryCitation: null, }, + threadId: nativeThreadId, + turnId: nativeTurnId, + startedAtMs: 1782622440500 + index, }, - { - type: "emit_inbound" as const, - label: "turn/completed/child", - frame: { - method: "turn/completed", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "completed", - }), - }, - }, + }, + }, + { + type: "emit_inbound", + label: `item/agentMessage/delta/${answer.id}`, + frame: { + method: "item/agentMessage/delta", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + itemId: answer.id, + delta: answer.text, }, - ] - : []), - ], - }); - const resetReceipt = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, - ); + }, + }, + completed, + ]; + }, + ), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + }; + + it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => + Effect.scoped( + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ + { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, + { id: "answer-empty", text: "" }, + ]); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), + now, + attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), + text: "Reply with the requested recovery marker.", }), ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, scenario.expectedClass); - assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "known-reset" || scenario.name === "deferred-reset") - assert.equal(terminal.failure.resetAt, resetAt); - if (scenario.name === "matching-details") - assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); - if (scenario.name === "late-reset") { - yield* Deferred.await(resetReceipt); - const item = harness.events.find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt, - ); - assert.isDefined(item); - } - if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); + yield* harness.awaitTerminal(1); + + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK"], + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -7339,578 +2635,785 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - const guardedPolicy: SubscriptionLimitPolicy.SubscriptionLimitPolicy = { - enabled: () => Effect.succeed(true), - enabledChanges: () => Stream.never, - }; - const rateLimitsRead = (id: number, usedPercent: number) => - [ + const BG_SCENARIO = "codex-bg-exec-wake"; + const BG_NATIVE_THREAD = "native-codex-bg-thread"; + const BG_NATIVE_TURN = "native-codex-bg-turn"; + const BG_COMMAND_ITEM = "call-codex-bg-command"; + const BG_COMMAND = "sleep 20 && echo CODEX_BG_WAKE_DONE"; + const BG_PROMPT = "Start the sleep in the background and reply STARTED."; + + const backgroundCommandItem = (status: "inProgress" | "completed"): Record => ({ + type: "commandExecution", + id: BG_COMMAND_ITEM, + command: BG_COMMAND, + cwd: "/workspace", + processId: "4242", + source: "unifiedExecStartup", + status, + commandActions: [{ type: "unknown", command: BG_COMMAND }], + aggregatedOutput: status === "completed" ? "CODEX_BG_WAKE_DONE\n" : null, + exitCode: status === "completed" ? 0 : null, + durationMs: status === "completed" ? 25_000 : null, + }); + + const backgroundExecTranscript = makeCodexReplayTranscript({ + scenario: BG_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: BG_NATIVE_THREAD, + nativeTurnId: BG_NATIVE_TURN, + prompt: BG_PROMPT, + }), { - type: "expect_outbound", - label: "account/rateLimits/read", - frame: { id, method: "account/rateLimits/read", params: null }, + type: "emit_inbound", + label: "item/started/command", + frame: { + method: "item/started", + params: { + item: backgroundCommandItem("inProgress"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, }, { type: "emit_inbound", - label: "account/rateLimits/read", + label: "item/completed/root-answer", frame: { - id, - result: { - rateLimits: { - limitId: "codex", - primary: { usedPercent, resetsAt: 2000100000, windowDurationMins: 300 }, + method: "item/completed", + params: { + item: { + type: "agentMessage", + id: "root-answer-bg", + text: "STARTED", + phase: "final_answer", + memoryCitation: null, }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622441000, }, }, }, - ] satisfies Array; + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: BG_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: BG_NATIVE_TURN, status: "completed" }), + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/command-late", + afterMs: 30_000, + frame: { + method: "item/completed", + params: { + item: backgroundCommandItem("completed"), + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, + }, + }, + }, + ], + }); - it.effect("refuses a guarded turn before Codex starts it once the allowance is used up", () => - Effect.gen(function* () { - const preamble = codexReplayPreamble({ - nativeThreadId: "native-guard-refused", - nativeTurnId: "turn-guard-refused", - prompt: "Continue.", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-subscription-guard-refused", - entries: [...preamble.slice(0, 5), ...rateLimitsRead(3, 100)], - }); - const harness = yield* makeCodexReplayHarness( - transcript, - undefined, - undefined, - undefined, - guardedPolicy, - ); - const error = yield* harness.runtime - .startTurn( + it.effect.each([false, true])( + "keeps commands interrupted after native thread closure (persistent tool: %s)", + (retainContext) => + Effect.gen(function* () { + const notification = (method: string, params: unknown) => ({ + type: "emit_inbound" as const, + label: method, + frame: { method, params }, + }); + const monitor = (status: "inProgress" | "completed") => + notification(status === "inProgress" ? "item/started" : "item/completed", { + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + item: { + type: "dynamicToolCall", + id: "persistent-monitor", + namespace: "grok", + tool: "monitor", + status, + arguments: { persistent: true, command: "tail -f" }, + }, + }); + // Replies fence notification handling on the same protocol reader. + const unsubscribe = (id: number, notifications: ReturnType[] = []) => [ + { + type: "expect_outbound" as const, + label: `unsubscribe-${id}`, + frame: { id, method: "thread/unsubscribe", params: { threadId: BG_NATIVE_THREAD } }, + }, + ...notifications, + { + type: "emit_inbound" as const, + label: `unsubscribe-result-${id}`, + frame: { id, result: { status: "notSubscribed" } }, + }, + ]; + const closed = notification("thread/closed", { threadId: BG_NATIVE_THREAD }); + const transcript = makeCodexReplayTranscript({ + scenario: `codex-background-thread-closed-${retainContext}`, + entries: [ + ...backgroundExecTranscript.entries.slice(0, -2), + ...(retainContext ? [monitor("inProgress")] : []), + backgroundExecTranscript.entries.at(-2)!, + ...unsubscribe(4), + ...unsubscribe(5, [notification("thread/closed", { threadId: "unrelated-thread" })]), + ...unsubscribe(6, [closed]), + ...unsubscribe(7, [ + closed, + notification("item/completed", { + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + item: backgroundCommandItem("completed"), + }), + ...(retainContext ? [monitor("completed")] : []), + ]), + ], + }); + const interrupted = yield* Deferred.make(); + const monitorCompleted = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => { + if (event.type !== "turn_item.updated") return Effect.void; + if ( + event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM && + event.turnItem.status === "interrupted" + ) { + return Deferred.succeed(interrupted, undefined); + } + return event.turnItem.nativeItemRef?.nativeId === "persistent-monitor" && + event.turnItem.status === "completed" + ? Deferred.succeed(monitorCompleted, undefined) + : Effect.void; + }); + yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make("attempt-guard-refused"), + attemptId: RunAttemptId.make("attempt-codex-thread-closed"), + text: BG_PROMPT, }), - ) - .pipe(Effect.flip); - const failure = SubscriptionLimitPolicy.findSubscriptionLimitStop(error); - assert.equal(failure?.class, "usage_limit"); - assert.equal(failure?.code, "subscription_limit_guard"); - assert.equal(failure?.resetAt, "2033-05-19T07:20:00.000Z"); - assert.lengthOf(harness.terminalEvents(), 0); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), - ); - - it.effect("stops a running guarded turn when Codex reports the allowance used up", () => - Effect.gen(function* () { - const nativeThreadId = "native-guard-live"; - const nativeTurnId = "turn-guard-live"; - const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-subscription-guard-live", - entries: [ - ...preamble.slice(0, 5), - ...rateLimitsRead(3, 40), - ...preamble.slice(5).map((entry) => withReplayRequestId(entry, 4)), - { - type: "emit_inbound", - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, - }, - }, - }, + ); + yield* harness.firstTerminal; + const unload = harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + yield* unload; + assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* unload; + assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* unload; + assert.equal(yield* harness.hasPendingBackgroundWork, retainContext); + yield* Deferred.await(interrupted); + yield* unload; + if (retainContext) yield* Deferred.await(monitorCompleted); + assert.deepEqual( { - type: "expect_outbound", - label: "turn/interrupt", - frame: { - id: 5, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, + items: harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === BG_COMMAND_ITEM + ? [event.turnItem.status] + : [], + ), + nodes: harness.events.flatMap((event) => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === BG_COMMAND_ITEM + ? [event.node.status] + : [], + ), + roots: harness.terminalEvents().map((event) => event.status), + continuations: harness.continuationRequests.length, }, - { type: "emit_inbound", label: "turn/interrupt", frame: { id: 5, result: {} } }, { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), - }, - }, + items: ["running", "interrupted"], + nodes: ["running", "interrupted"], + roots: ["completed"], + continuations: 0, }, - ], - }); - const harness = yield* makeCodexReplayHarness( - transcript, - undefined, - undefined, - undefined, - guardedPolicy, - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make("attempt-guard-live"), - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, "usage_limit"); - assert.equal(terminal.failure.code, "subscription_limit_guard"); - assert.equal(terminal.failure.resetAt, "2033-05-19T07:20:00.000Z"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!(harness.providerThread), + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), - ), ); - it.effect.each([ - { - notificationStatus: 500, - completionStatus: 429, - willRetry: false, - expectedClass: "usage_limit", - }, - { - notificationStatus: 429, - completionStatus: 500, - willRetry: true, - expectedClass: "provider_error", - }, - { - notificationStatus: 429, - completionStatus: null, - willRetry: false, - expectedClass: "usage_limit", - }, - { - notificationStatus: 429, - completionStatus: null, - willRetry: true, - expectedClass: "usage_limit", - }, - ] as const)( - "honors completion HTTP status from $notificationStatus to $completionStatus (retry: $willRetry)", - ({ notificationStatus, completionStatus, expectedClass, willRetry }) => - Effect.scoped( - Effect.gen(function* () { - const name = `${notificationStatus}-${completionStatus}-${willRetry}`; - const nativeThreadId = `native-exhaustion-status-${name}`; - const nativeTurnId = `turn-exhaustion-status-${name}`; - const message = "Provider stopped this request."; - const additionalDetails = "Detailed notification evidence."; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-exhaustion-status-${name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; + const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { + const stillRunning = terminated === "still_running"; + return makeCodexReplayTranscript({ + scenario: `codex-bg-stop-${terminated}`, + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "expect_outbound", + label: "terminate-background-command", + frame: { + id: 4, + method: "thread/backgroundTerminals/terminate", + params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, + }, + }, + { + type: "emit_inbound", + label: "terminate-background-command", + frame: + terminated === "thread_unloaded" + ? { + id: 4, + error: { code: -32600, message: `thread not found: ${BG_NATIVE_THREAD}` }, + } + : { id: 4, result: { terminated: terminated === true } }, + }, + ...(terminated === false || stillRunning + ? [ { - type: "emit_inbound", - label: "error", + type: "expect_outbound" as const, frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry, - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: notificationStatus }, - }, - additionalDetails, - }, - }, + id: 5, + method: "thread/backgroundTerminals/list", + params: { threadId: BG_NATIVE_THREAD }, }, }, { - type: "emit_inbound", - label: "turn/completed", + type: "emit_inbound" as const, frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: completionStatus }, - }, - }, - }, - }, + id: 5, + result: { data: stillRunning ? [{ processId: "4242" }] : [], nextCursor: null }, }, }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); + ] + : []), + ...(stillRunning + ? [ + { + type: "expect_outbound" as const, + frame: { + id: 6, + method: "thread/backgroundTerminals/terminate", + params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, + }, + }, + { + type: "emit_inbound" as const, + frame: { id: 6, result: { terminated: false } }, + }, + { + type: "expect_outbound" as const, + frame: { + id: 7, + method: "thread/backgroundTerminals/list", + params: { threadId: BG_NATIVE_THREAD }, + }, + }, + { + type: "emit_inbound" as const, + frame: { id: 7, result: { data: [{ processId: "4242" }], nextCursor: null } }, + }, + { + type: "expect_outbound" as const, + frame: { + id: 8, + method: "thread/backgroundTerminals/terminate", + params: { threadId: BG_NATIVE_THREAD, processId: "4242" }, + }, + }, + { + type: "emit_inbound" as const, + frame: { id: 8, result: { terminated: true } }, + }, + ] + : []), + backgroundExecTranscript.entries.at(-1)!, + ], + }); + }; + + it.effect.each(backgroundStopCases)( + "stops a command after root completion when termination returns %s", + (terminated) => { + const stillRunning = terminated === "still_running"; + const transcript = makeBackgroundStopTranscript(terminated); + return Effect.scoped( + Effect.gen(function* () { + const stopped = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "interrupted" + ? Deferred.succeed(stopped, undefined) + : Effect.void, + ); + const now = yield* DateTime.now; yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-exhaustion-status-${name}`), + now, + attemptId: RunAttemptId.make("attempt-codex-bg-stop"), + text: BG_PROMPT, }), ); yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, expectedClass); - assert.equal(terminal.failure.code, "responseTooManyFailedAttempts"); - assert.equal(terminal.threadDisposition, "reusable"); - const notificationClass = notificationStatus === 429 ? "usage_limit" : "provider_error"; - assert.equal( - terminal.failure.message, - expectedClass === notificationClass ? additionalDetails : message, + const terminal = harness.terminalEvents()[0]!; + assert.equal(terminal.status, "completed"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!({ + ...harness.providerThread, + id: ProviderThreadId.make("unrelated-provider-thread"), + }), ); - if (willRetry) assert.equal(terminal.retry?.attempt, 1); + if (stillRunning) { + const failed = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.exit); + assert.equal(failed._tag, "Failure"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: stillRunning + ? ProviderTurnId.make("later-completed-turn") + : terminal.providerTurnId, + requestRuntimeRestart: true, + }); + yield* Deferred.await(stopped); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.terminalEvents(), 1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 0); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), - ), + ); + }, ); - - const FAILED_SCENARIO = "codex-failed-mid-command"; - const FAILED_NATIVE_THREAD = "native-codex-failed-thread"; - const FAILED_NATIVE_TURN = "native-codex-failed-turn"; - const FAILED_COMMAND_ITEM = "exec-codex-failed-command"; - const FAILED_COMMAND = "sleep 30"; - const FAILED_PROMPT = "Run a command that will be abandoned when the turn fails."; - - const failedMidCommandTranscript = makeCodexReplayTranscript({ - scenario: FAILED_SCENARIO, - entries: [ - ...codexReplayPreamble({ - nativeThreadId: FAILED_NATIVE_THREAD, - nativeTurnId: FAILED_NATIVE_TURN, - prompt: FAILED_PROMPT, - }), - { - type: "emit_inbound", - label: "item/started/command", - frame: { - method: "item/started", - params: { - item: { - type: "commandExecution", - id: FAILED_COMMAND_ITEM, - command: FAILED_COMMAND, - cwd: "/workspace", - processId: "99", - source: "unifiedExecStartup", - status: "inProgress", - commandActions: [{ type: "unknown", command: FAILED_COMMAND }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, - threadId: FAILED_NATIVE_THREAD, - turnId: FAILED_NATIVE_TURN, - startedAtMs: 1782622440500, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: FAILED_NATIVE_THREAD, - turn: { - ...makeCodexReplayTurn({ - id: FAILED_NATIVE_TURN, - status: "failed", + it.effect("stops a completed run's background command without a client-supplied run", () => { + const transcript = makeBackgroundStopTranscript(true); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(transcript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), + ); + const replayDriver = yield* CodexReplay.makeReplayDriver(localTranscript); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + assert.equal( + Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), + 0, + ); + assert.equal( + Number( + yield* spawner.exitCode( + ChildProcess.make( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + { cwd }, + ), + ), + ), + 0, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop"), + threadId, + projectId: ProjectId.make("project:background-stop"), + title: "Background stop", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const waiting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop"), + threadId, + messageId: MessageId.make("message:background-stop"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + assert.isNull((yield* Ref.get(replayDriver.state)).failure); + yield* Fiber.join(waiting); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.at(-1)!; + assert.equal(run.status, "completed"); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, + 1, + ); + const stopped = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-background-command"), + threadId, + }); + yield* worker.drain(); + yield* Fiber.join(stopped); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ + transcript: localTranscript, + driver: replayDriver, }), - error: { message: "provider failed mid-command" }, - }, - }, - }, - }, - ], + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); - it.effect("terminalizes running command items before turn.terminal on failed turns", () => + it.effect("Stop ends a background command no Codex process tracks any more", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(failedMidCommandTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-failed-mid-command"), - text: FAILED_PROMPT, - }), - ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - const failedCommandIndex = harness.events.findIndex( - (event, index) => - index < terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "failed", + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stale-workspace-" }); + const staleTranscript = makeCodexReplayTranscript({ + scenario: "codex-bg-stop-untracked", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { type: "runtime_exit", status: "success" }, + ], + }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), ); - assert.isAtLeast(failedCommandIndex, 0); - assert.isAbove( - terminalIndex, - failedCommandIndex, - "failed-turn command terminalization must precede turn.terminal", + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const args of [ + ["init", "--quiet"], + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + ]) { + assert.equal(Number(yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }))), 0); + } + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop-untracked"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop-untracked"), + threadId, + projectId: ProjectId.make("project:background-stop-untracked"), + title: "Background stop untracked", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop-untracked"), + threadId, + messageId: MessageId.make("message:background-stop-untracked"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* Fiber.join(settled); + yield* worker.drain(); + const before = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual( + before?.pendingBackgroundTasks?.map((task) => task.kind), + ["command"], + "the thread still shows the command the gone process never finished", + ); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-untracked"), + threadId, + runId: run.id, + holdQueue: true, + }); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.turnItems.flatMap((item) => + item.type === "command_execution" ? [item.status] : [], + ), + ["interrupted"], + ); + assert.equal(projection.runs.at(-1)?.status, "completed"); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ transcript: localTranscript }), + { runEffectWorker: false }, + ), + ), ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), + }).pipe(Effect.provide(NodeServices.layer)), ), ); - const MCP_APP_SCENARIO = "codex-mcp-app-capture"; - const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; - const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; - const MCP_APP_ITEM = "mcp-weather-call"; - const MCP_APP_PROMPT = "Show the weather in Oslo."; - const MCP_APP_RESOURCE = "ui://weather/dashboard"; - const MCP_APP_HTML = "

Weather

"; - const mcpAppToolItem = (status: "inProgress" | "completed") => ({ - type: "mcpToolCall", - id: MCP_APP_ITEM, - server: "weather", - tool: "get_weather", + const INTERRUPT_SCENARIO = "codex-interrupt-mid-command"; + const INTERRUPT_NATIVE_THREAD = "native-codex-interrupt-thread"; + const INTERRUPT_NATIVE_TURN = "native-codex-interrupt-turn"; + const INTERRUPT_COMMAND_ITEM = "exec-codex-interrupt-command"; + const INTERRUPT_COMMAND_ITEM_TWO = "exec-codex-interrupt-command-two"; + const INTERRUPT_CHILD_COMMAND_ITEM = "exec-codex-interrupt-child-command"; + const INTERRUPT_CHILD_NATIVE_THREAD = "native-codex-interrupt-child-thread"; + const INTERRUPT_CHILD_NATIVE_TURN = "native-codex-interrupt-child-turn"; + const INTERRUPT_LATE_CHILD_NATIVE_TURN = "native-codex-interrupt-late-child-turn"; + const INTERRUPT_COMMAND = "bash -c 'sleep 30; echo SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE'"; + const INTERRUPT_COMMAND_TWO = "bash -c 'sleep 20; echo SECOND_COMMAND'"; + const INTERRUPT_PROMPT = "Run a long foreground command and wait until interrupted."; + + const interruptCommandItem = (status: "inProgress" | "completed"): Record => ({ + type: "commandExecution", + id: INTERRUPT_COMMAND_ITEM, + command: INTERRUPT_COMMAND, + cwd: "/workspace", + processId: "57680", + source: "unifiedExecStartup", status, - arguments: { city: "Oslo" }, - mcpAppResourceUri: MCP_APP_RESOURCE, - ...(status === "completed" - ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } - : {}), + commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND }], + aggregatedOutput: status === "completed" ? "SHOULD_NOT_FINISH_CMD_INTERRUPT_FIXTURE\n" : null, + exitCode: status === "completed" ? 0 : null, + durationMs: status === "completed" ? 30_000 : null, }); - const mcpAppTranscript = makeCodexReplayTranscript({ - scenario: MCP_APP_SCENARIO, + const interruptMidCommandTranscript = makeCodexReplayTranscript({ + scenario: INTERRUPT_SCENARIO, entries: [ ...codexReplayPreamble({ - nativeThreadId: MCP_APP_NATIVE_THREAD, - nativeTurnId: MCP_APP_NATIVE_TURN, - prompt: MCP_APP_PROMPT, + nativeThreadId: INTERRUPT_NATIVE_THREAD, + nativeTurnId: INTERRUPT_NATIVE_TURN, + prompt: INTERRUPT_PROMPT, }), { type: "emit_inbound", - label: "item/started/app-tool", + label: "item/started/command", frame: { method: "item/started", params: { - item: mcpAppToolItem("inProgress"), - threadId: MCP_APP_NATIVE_THREAD, - turnId: MCP_APP_NATIVE_TURN, + item: interruptCommandItem("inProgress"), + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, startedAtMs: 1782622440500, }, }, }, { - type: "emit_inbound", - label: "item/completed/app-tool", + type: "expect_outbound", + label: "turn/interrupt", frame: { - method: "item/completed", + id: 4, + method: "turn/interrupt", params: { - item: mcpAppToolItem("completed"), - threadId: MCP_APP_NATIVE_THREAD, - turnId: MCP_APP_NATIVE_TURN, - completedAtMs: 1782622441500, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, }, }, }, { type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: MCP_APP_NATIVE_THREAD, - turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), - }, - }, - }, - { - type: "expect_outbound", - label: "mcpServer/resource/read", - frame: { - id: 4, - method: "mcpServer/resource/read", - params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, - }, + label: "turn/interrupt", + frame: { id: 4, result: {} }, }, { type: "emit_inbound", - label: "mcpServer/resource/read", + label: "item/started/command-two-after-interrupt-response", frame: { - id: 4, - result: { - contents: [ - { - uri: MCP_APP_RESOURCE, - mimeType: "text/html;profile=mcp-app", - text: MCP_APP_HTML, - _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, - }, - ], - }, - }, - }, - ], - }); - - it.effect("captures an MCP app's resource and holds the turn open until it lands", () => - Effect.scoped( - Effect.gen(function* () { - const appCaptured = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed" - ? Deferred.succeed(appCaptured, event.turnItem) - : Effect.void, - ); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-mcp-app"), - text: MCP_APP_PROMPT, - }), - ); - const item = yield* Deferred.await(appCaptured); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - - const completedRows = harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed", - ); - assert.lengthOf(completedRows, 1); - - assert.equal(item.type, "dynamic_tool"); - const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; - const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); - assert.deepEqual( - { ...reference, attachmentId: undefined }, - { - attachmentId: undefined, - server: "weather", - tool: "get_weather", - resourceUri: MCP_APP_RESOURCE, - csp: { connectDomains: ["https://api.weather.test"] }, + method: "item/started", + params: { + item: { + ...interruptCommandItem("inProgress"), + id: INTERRUPT_COMMAND_ITEM_TWO, + command: INTERRUPT_COMMAND_TWO, + processId: "57681", + commandActions: [{ type: "unknown", command: INTERRUPT_COMMAND_TWO }], + }, + threadId: INTERRUPT_NATIVE_THREAD, + turnId: INTERRUPT_NATIVE_TURN, + startedAtMs: 1782622440600, }, - ); - assert.deepEqual(output.result, { - content: [{ type: "text", text: "Sunny" }], - structuredContent: { temp: 21 }, - }); - - const fileSystem = yield* FileSystem.FileSystem; - const stored = yield* fileSystem.readFileString( - resolveAttachmentPathById({ - attachmentsDir: harness.serverConfig.attachmentsDir, - attachmentId: reference!.attachmentId, - })!, - ); - assert.include(stored, "connect-src https://api.weather.test"); - assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(""; + const mcpAppToolItem = (status: "inProgress" | "completed") => ({ + type: "mcpToolCall", + id: MCP_APP_ITEM, + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Oslo" }, + mcpAppResourceUri: MCP_APP_RESOURCE, + ...(status === "completed" + ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } + : {}), }); - const codexUsageEvent = (input: { - readonly threadId: string; - readonly turnId: string; - readonly total: ReturnType; - readonly last: ReturnType; - }): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `thread/tokenUsage/updated/${input.turnId}/${input.total.totalTokens}`, - frame: { - method: "thread/tokenUsage/updated", - params: { - threadId: input.threadId, - turnId: input.turnId, - tokenUsage: { - total: input.total, - last: input.last, - modelContextWindow: 200_000, + const mcpAppTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: MCP_APP_NATIVE_THREAD, + nativeTurnId: MCP_APP_NATIVE_TURN, + prompt: MCP_APP_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/app-tool", + frame: { + method: "item/started", + params: { + item: mcpAppToolItem("inProgress"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + startedAtMs: 1782622440500, + }, }, }, - }, - }); - - const codexCommandEvents = (input: { - readonly threadId: string; - readonly turnId: string; - readonly id: string; - readonly command: string; - }): Array => { - const item = (status: "inProgress" | "completed") => ({ - type: "commandExecution" as const, - id: input.id, - pluginId: null, - scriptPath: null, - command: input.command, - cwd: "/workspace", - processId: "1", - source: "unifiedExecStartup" as const, - status, - commandActions: [{ type: "unknown" as const, command: input.command }], - aggregatedOutput: status === "completed" ? "ok\n" : null, - exitCode: status === "completed" ? 0 : null, - durationMs: status === "completed" ? 1 : null, - }); - return (["inProgress", "completed"] as const).map((status) => ({ - type: "emit_inbound" as const, - label: `item/${status === "inProgress" ? "started" : "completed"}/${input.id}`, - frame: { - method: status === "inProgress" ? "item/started" : "item/completed", - params: { - threadId: input.threadId, - turnId: input.turnId, - ...(status === "inProgress" ? { startedAtMs: 1 } : { completedAtMs: 2 }), - item: item(status), + { + type: "emit_inbound", + label: "item/completed/app-tool", + frame: { + method: "item/completed", + params: { + item: mcpAppToolItem("completed"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, }, }, - })); - }; - - const commandTurnItems = ( - events: ReadonlyArray, - threadId: ThreadId, - ) => { - const updates = new Map< - string, - Array> - >(); - for (const event of events) { - if (event.type !== "turn_item.updated" || event.turnItem.type !== "command_execution") { - continue; - } - if (event.turnItem.threadId !== threadId) continue; - const current = updates.get(event.turnItem.input) ?? []; - current.push(event.turnItem); - updates.set(event.turnItem.input, current); - } - return updates; - }; - - it.effect("attributes a thread's tool calls to each Codex model response", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "codex-model-call-thread"; - const nativeTurnId = "codex-model-call-turn"; - const prompt = "Run the commands."; - const first = codexTokenBreakdown({ - inputTokens: 800, - cachedInputTokens: 100, - outputTokens: 50, - reasoningOutputTokens: 10, - }); - const secondLast = codexTokenBreakdown({ - inputTokens: 400, - cachedInputTokens: 20, - outputTokens: 30, - reasoningOutputTokens: 0, - }); - const secondTotal = codexTokenBreakdown({ - inputTokens: 1_200, - cachedInputTokens: 120, - outputTokens: 80, - reasoningOutputTokens: 10, - }); - const textOnly = codexTokenBreakdown({ - inputTokens: 90, - cachedInputTokens: 0, - outputTokens: 10, - reasoningOutputTokens: 0, - }); - const textOnlyTotal = codexTokenBreakdown({ - inputTokens: 1_290, - cachedInputTokens: 120, - outputTokens: 90, - reasoningOutputTokens: 10, - }); - const reset = codexTokenBreakdown({ - inputTokens: 40, - cachedInputTokens: 8, - outputTokens: 12, - reasoningOutputTokens: 2, - }); - const fileUsage = codexTokenBreakdown({ - inputTokens: 15, - cachedInputTokens: 4, - outputTokens: 6, - reasoningOutputTokens: 1, - }); - const usage = ( - total: ReturnType, - last: ReturnType, - ) => codexUsageEvent({ threadId: nativeThreadId, turnId: nativeTurnId, total, last }); - const command = (id: string, commandText: string) => - codexCommandEvents({ - threadId: nativeThreadId, - turnId: nativeTurnId, - id, - command: commandText, - }); - const fileId = "file-after-usage"; - const fileChange = ( - status: "inProgress" | "completed", - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `file/${status}`, - frame: { - method: status === "inProgress" ? "item/started" : "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - ...(status === "inProgress" ? { startedAtMs: 3 } : { completedAtMs: 4 }), - item: { - type: "fileChange", - id: fileId, - status, - changes: [ - { - path: "/workspace/note.txt", - kind: { type: "add" }, - diff: "note\n", - }, - ], - }, - }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: MCP_APP_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), }, - }); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-model-call-usage", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...command("exec-a", "echo a"), - ...command("exec-b", "echo b"), - usage(first, first), - usage(first, first), - ...command("exec-c", "echo c"), - usage(secondTotal, secondLast), - usage(textOnlyTotal, textOnly), - ...command("exec-d", "echo d"), - usage(reset, reset), - fileChange("inProgress"), - usage(fileUsage, fileUsage), - fileChange("completed"), + }, + }, + { + type: "expect_outbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + method: "mcpServer/resource/read", + params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, + }, + }, + { + type: "emit_inbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + result: { + contents: [ { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, }, ], - }), + }, + }, + }, + ], + }); + + it.effect("captures an MCP app's resource and holds the turn open until it lands", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, ); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-model-call"), - text: prompt, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app"), + text: MCP_APP_PROMPT, }), ); - yield* harness.firstTerminal; + const item = yield* Deferred.await(appCaptured); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + + const completedRows = harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.lengthOf(completedRows, 1); + + assert.equal(item.type, "dynamic_tool"); + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); + assert.deepEqual( + { ...reference, attachmentId: undefined }, + { + attachmentId: undefined, + server: "weather", + tool: "get_weather", + resourceUri: MCP_APP_RESOURCE, + csp: { connectDomains: ["https://api.weather.test"] }, + }, + ); + assert.deepEqual(output.result, { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temp: 21 }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const stored = yield* fileSystem.readFileString( + resolveAttachmentPathById({ + attachmentsDir: harness.serverConfig.attachmentsDir, + attachmentId: reference!.attachmentId, + })!, + ); + assert.include(stored, "connect-src https://api.weather.test"); + assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(" - `); - // Chromium opens a data: URL whose type is malformed too, and quotes it unchanged. - for (const url of ["data:text/plain,head PAYLOAD'tail x", "data:text/plain charset,PAYLOAD"]) { - const blocked = page.waitForEvent("console"); - await page.evaluate(`location.href = ${JSON.stringify(url)}`); - await blocked; - } - expect(logged).toEqual([ - "Downloaded data:text/plain,example", - "Loading the image 'data:image/png,…", - "Not allowed to navigate top frame to data URL: data:text/plain,…", - "Not allowed to navigate top frame to data URL: data:,…", - ]); - }); - it("rejects refs from another tab", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label"); @@ -300,16 +278,6 @@ describe("server browser element refs", () => { await expect(ServerBrowserPage.click(other, { locator })).rejects.toThrow(/another tab/); }); - it("revokes refs on takeover and issues usable refs in the next snapshot", async () => { - await page.setContent(""); - const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue"); - ServerBrowserPage.invalidateRefs(page); - await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/); - const fresh = buttonLocator((await takeSnapshot()).accessibilityTree, "continue"); - await ServerBrowserPage.click(page, { locator: fresh }); - expect(await page.locator("button").textContent()).toBe("clicked"); - }); - it("revokes refs after navigation even when labels are identical", async () => { await page.goto("data:text/html,"); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue"); @@ -317,13 +285,6 @@ describe("server browser element refs", () => { await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/); }); - it("only accepts refs from the most recent snapshot", async () => { - await page.setContent(""); - const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue"); - await takeSnapshot(); - await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/); - }); - it.each(["aria-ref=e1", " aria-ref=e1", "css=body >> aria-ref=e1"])( "does not allow native refs to bypass generation validation (%s)", async (locator) => { @@ -333,14 +294,6 @@ describe("server browser element refs", () => { }, ); - it("preserves CSS selectors containing an aria-ref attribute", async () => { - await page.setContent( - '', - ); - await ServerBrowserPage.click(page, { selector: 'button[aria-ref="save"]' }); - expect(await page.locator("button").textContent()).toBe("saved"); - }); - it("preserves quoted attribute values containing ref engine text", async () => { await page.setContent( '', @@ -349,94 +302,6 @@ describe("server browser element refs", () => { expect(await page.locator("button").textContent()).toBe("saved"); }); - it("right-clicks, double-clicks, hovers, selects, and drags like a pointer user", async () => { - await page.setContent(` - - -
hover me
- -
card
lane
-

- `); - await ServerBrowserPage.click(page, { locator: "#target", button: "right" }); - await ServerBrowserPage.click(page, { locator: "#target", clickCount: 2 }); - await ServerBrowserPage.hover(page, { locator: "#hover" }); - expect(await page.isVisible("#menu")).toBe(true); - expect(await ServerBrowserPage.select(page, { locator: "#size", values: ["Large"] })).toEqual({ - selected: ["l"], - }); - await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }); - expect(await page.textContent("#log")).toBe("context;dblclick;drop;"); - await expect( - ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }), - ).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" }); - }); - - it("shows the agent's pointer at each target before the action reaches the page", async () => { - await page.setContent(` - -
tip
-
card
-
lane
- `); - const shown: Array = []; - const pointer: ServerBrowserPage.PointerReporter = async ({ x, y }, phase) => { - const seen = await page.evaluate("window.seen.length"); - shown.push(`${phase}@${Math.round(x)},${Math.round(y)} after ${seen}`); - }; - await ServerBrowserPage.click(page, { locator: "#go" }, pointer); - await ServerBrowserPage.hover(page, { locator: "#tip" }, pointer); - await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }, pointer); - expect(shown).toEqual([ - "click@140,50 after 0", - "move@330,50 after 1", - "move@40,140 after 2", - "move@270,140 after 2", - ]); - expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]); - }); - - it("presents a headless page as Chrome, with client hints that agree", async () => { - await presentAsChrome(cdp, { platform: "linux", arch: "x64" }); - // userAgentData exists only in secure contexts; https comes from a route. - let secChUa: string | undefined; - await page.route("https://example.test/", (route) => { - secChUa = route.request().headers()["sec-ch-ua"]; - return route.fulfill({ contentType: "text/html", body: "

hi

" }); - }); - await page.goto("https://example.test/"); - const identity = (await page.evaluate(`(async () => ({ - userAgent: navigator.userAgent, - brands: navigator.userAgentData.brands.map((brand) => brand.brand), - full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"])) - .fullVersionList.map((brand) => brand.brand), - }))()`)) as { userAgent: string; brands: string[]; full: string[] }; - expect(identity.userAgent).not.toContain("Headless"); - expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /); - expect(identity.brands).toContain("Google Chrome"); - expect([...identity.brands, ...identity.full].join()).not.toContain("Headless"); - expect(secChUa).toContain('"Google Chrome"'); - expect(secChUa).not.toContain("Headless"); - }); - it("stops an evaluation at its deadline so the page answers the next one", async () => { await expect( ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, { timeoutMs: 200 }), @@ -473,8 +338,10 @@ describe("server browser drag", () => { ); void settled.then(() => (finished = true)); // Two macrotask turns: Node reports a rejection nobody observed after the first. - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); + for (let turn = 0; turn < 2; turn++) { + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- Node emits unhandledRejection only between macrotasks; no promise can mark that point. + await new Promise((resolve) => setImmediate(resolve)); + } expect(finished).toBe(false); targetBox.resolve(box); @@ -518,13 +385,6 @@ it("captures the native display viewport after page script reassigns the globals return size; }; expect(await snapshotSize()).toEqual({ width: 1280, height: 800 }); - expect(await ServerBrowserPage.readPageMetrics(cdp)).toEqual({ - ratio: 1.5, - width: 1280, - height: 800, - zoom: 1, - offset: { x: 0, y: 0 }, - }); // Window bounds are ignored by headless Chromium on some hosts; a metrics override resizes // the page everywhere, and Playwright's viewportSize() stays stale as it would on desktop. await cdp.send("Emulation.setDeviceMetricsOverride", { @@ -533,11 +393,6 @@ it("captures the native display viewport after page script reassigns the globals deviceScaleFactor: 0, mobile: false, }); - expect(await ServerBrowserPage.readPageMetrics(cdp)).toMatchObject({ - ratio: 1.5, - width: 390, - height: 844, - }); expect(await snapshotSize()).toEqual({ width: 585, height: 1266 }); } finally { await browser.close(); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index a244c60710bf..1d09bfe27478 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -532,7 +532,7 @@ type Viewport = { readonly width: number; readonly height: number }; * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. */ -export const readPageMetrics = async (cdp: CDPSession) => { +const readPageMetrics = async (cdp: CDPSession) => { const { frameTree } = await cdp.send("Page.getFrameTree"); const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, diff --git a/apps/server/src/preview/SessionControl.test.ts b/apps/server/src/preview/SessionControl.test.ts index bcebdb9bbfc1..7198da812ef2 100644 --- a/apps/server/src/preview/SessionControl.test.ts +++ b/apps/server/src/preview/SessionControl.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts"; @@ -196,16 +196,6 @@ describe("SessionControl", () => { expect(control.generation).toBe(1); }); - it("does not let a failed action poison the serial queue", async () => { - const control = new SessionControl("agent"); - await expect( - control.agent("agent", async () => { - throw new Error("navigation failed"); - }), - ).rejects.toThrow("navigation failed"); - await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered"); - }); - it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => { const control = new SessionControl(null); await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted"); @@ -218,37 +208,3 @@ describe("SessionControl", () => { await expect(control.agent("agent-b", async () => "acted")).resolves.toBe("acted"); }); }); - -describe("request deadlines at dispatch", () => { - afterEach(() => vi.restoreAllMocks()); - - it("finishes running work, skips expired queued actions, and accepts fresh work", async () => { - let now = 0; - vi.spyOn(performance, "now").mockImplementation(() => now); - const control = new SessionControl("agent"); - const started = Promise.withResolvers(); - const finish = Promise.withResolvers(); - const events: string[] = []; - const running = control.agent( - "agent", - async () => { - events.push("started"); - started.resolve(); - await finish.promise; - events.push("finished"); - }, - 100, - ); - await started.promise; - const expired = expect( - control.agent("agent", async () => events.push("expired"), 100), - ).rejects.toMatchObject({ name: "TimeoutError" }); - const next = control.agent("agent", async () => events.push("next"), 200); - // No timer fires: expiry must be checked when the queue resumes. - now = 100; - expect(events).toEqual(["started"]); - finish.resolve(); - await Promise.all([running, expired, next]); - expect(events).toEqual(["started", "finished", "next"]); - }); -}); diff --git a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts b/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts deleted file mode 100644 index b9fee70bf39f..000000000000 --- a/apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - Entry point for the inherited-pipe shutdown regression test. -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Console from "effect/Console"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as ServerSettings from "../../serverSettings.ts"; - -import * as ServerConfig from "../../config.ts"; -import * as DesktopTelemetryReceiver from "../../resourceTelemetry/DesktopTelemetryReceiver.ts"; -import * as DesktopBrowserChannel from "../DesktopBrowserChannel.ts"; - -const key = { threadId: "thread-1", tabId: "tab-1" }; - -Effect.gen(function* () { - const base = yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pipe-lifecycle-" })), - ); - const context = yield* Layer.build( - Layer.merge(DesktopBrowserChannel.layer, DesktopTelemetryReceiver.layer).pipe( - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - Layer.succeed(ServerConfig.ServerConfig, { - ...base, - desktopBrowserFd: 3, - desktopBrowserControlFd: 4, - desktopTelemetryFd: 5, - desktopTelemetryControlFd: 6, - }), - ), - ), - ); - const browser = Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); - const telemetry = Context.get(context, DesktopTelemetryReceiver.DesktopTelemetryReceiver); - const health = yield* telemetry.subscribeHealth; - const healthy = yield* health.changes.pipe( - Stream.filter((value) => value.status === "healthy"), - Stream.runHead, - Effect.forkScoped, - ); - const detached = yield* browser.detached.pipe(Stream.runHead, Effect.forkScoped); - yield* Console.log("ready"); - if (!(yield* browser.awaitAttached(key, "5 seconds"))) { - return yield* Effect.die("The browser attach message was not received."); - } - yield* Fiber.join(healthy); - yield* Console.log("attached"); - yield* Fiber.join(detached); - const endpoint = yield* Effect.exit(Effect.scoped(browser.endpoint(key))); - if (Exit.isSuccess(endpoint)) return yield* Effect.die("A detached tab received an endpoint."); - yield* Console.log("verified"); -}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 6de420777873..30be9346dc1d 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -14,15 +14,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as ServerConfig from "../config.ts"; import { makeCodexAppServerSpawnCommand } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as ProcessRunner from "../processRunner.ts"; -import { - agentSliceMemoryLimits, - classifyScope, - make, - ownedAgentSlice, - type ScopeState, -} from "./agentScope.ts"; - -const GiB = 1024 ** 3; +import { classifyScope, make, ownedAgentSlice, type ScopeState } from "./agentScope.ts"; const running: ScopeState = { loadState: "loaded", @@ -41,26 +33,6 @@ describe("classifyScope", () => { expect(classifyScope({ ...running, populated: false })).toBe("stopping"); expect(classifyScope({ ...running, activeState: "deactivating" })).toBe("stopping"); }); - - it("does not wait on a live agent or a scope that ended for another reason", () => { - expect(classifyScope(running)).toBe("running"); - expect(classifyScope({ ...running, activeState: "inactive", populated: undefined })).toBe( - "gone", - ); - expect(classifyScope({ ...running, loadState: "not-found", activeState: "inactive" })).toBe( - "gone", - ); - }); -}); - -describe("agentSliceMemoryLimits", () => { - it("leaves 6 GB before throttling and 4 GB before a kill on large machines", () => { - expect(agentSliceMemoryLimits(64 * GiB)).toEqual({ high: 58 * GiB, max: 60 * GiB }); - }); - - it("still gives agents most of a small machine", () => { - expect(agentSliceMemoryLimits(8 * GiB)).toEqual({ high: 4 * GiB, max: 6 * GiB }); - }); }); const withFakeSystemd = ( @@ -188,21 +160,6 @@ describe("AgentScope service", () => { ), ); - it.effect("labels the shared Codex app-server's OOM kill on systemd without OOMPolicy", () => - withFakeSystemd( - ({ scope, units }) => - Effect.gen(function* () { - const codex = yield* launchCodex(scope, "a"); - units.set(unitOf(codex), { activeState: "failed", result: "oom-kill" }); - expect(yield* scope.oomKilled("a")).toBe(true); - expect(codex.args.filter((arg) => arg.includes("OOMPolicy"))).toEqual([]); - expect(codex.args).not.toContain("--collect"); - expect(codex.args).toContain("--property=TimeoutStopSec=10s"); - }), - { oomPolicy: false }, - ), - ); - it.effect("keeps a running scope while hundreds of other threads launch", () => withFakeSystemd(({ scope, units }) => Effect.gen(function* () { @@ -237,28 +194,4 @@ describe("AgentScope service", () => { }), ), ); - - it.effect("requests OOM continuation only for shared scopes when systemd supports it", () => - withFakeSystemd(({ scope }) => - Effect.gen(function* () { - const server = yield* scope.wrap({ command: "agent", args: [], name: "s", shared: true }); - const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); - expect(server.args.filter((arg) => arg.startsWith("--property=OOMPolicy="))).toEqual([ - "--property=OOMPolicy=continue", - ]); - expect(agent.args.filter((arg) => arg.startsWith("--property=OOMPolicy="))).toEqual([ - "--property=OOMPolicy=stop", - ]); - }), - ), - ); - - it.effect("leaves a quiet slice alone", () => - withFakeSystemd(({ scope, calls }) => - Effect.gen(function* () { - yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); - expect(calls.filter(([, verb]) => verb === "stop")).toEqual([]); - }), - ), - ); }); diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index 32461ac69f7f..74858468d1b8 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -33,7 +33,7 @@ const GiB = 1024 ** 3; * working. Small machines still give agents at least half of RAM before * throttling and three quarters before a kill. */ -export function agentSliceMemoryLimits(totalBytes: number) { +function agentSliceMemoryLimits(totalBytes: number) { return { high: Math.max(totalBytes - 6 * GiB, Math.floor(totalBytes / 2)), max: Math.max(totalBytes - 4 * GiB, Math.floor(totalBytes * 0.75)), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 8e46139a8717..4f8b971f15ae 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches. import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -12,13 +9,11 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -118,34 +113,6 @@ const layerTest = (input: { ); }; -it.effect("launches the default browser through the platform command", () => { - let spawned: ChildProcess.StandardCommand | undefined; - let didUnref = false; - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - yield* launcher.launchBrowser("https://example.com/some path"); - - assert.ok(spawned); - assert.equal(spawned.command, "xdg-open"); - assert.deepEqual(spawned.args, ["https://example.com/some path"]); - assert.equal(spawned.options.detached, true); - assert.equal(didUnref, true); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - onSpawn: (command) => { - spawned = command; - }, - onUnref: () => { - didUnref = true; - }, - }), - ), - ); -}); - it.effect.skipIf(windowsHost)( "launched browsers and editors do not inherit ELECTRON_RUN_AS_NODE", () => @@ -185,1056 +152,185 @@ it.effect.skipIf(windowsHost)( })), ["xdg-open", "code"].map((command) => ({ command, - env: { PATH: binDir, DISPLAY: ":0" }, - extendEnv: false, - })), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.each(["win32", "linux"] as const)( - "launches the browser through PowerShell on %s", - (platform) => { - let spawned: ChildProcess.StandardCommand | undefined; - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - yield* launcher.launchBrowser("https://example.com/some path"); - - assert.ok(spawned); - assert.equal( - spawned.command, - platform === "win32" - ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - : "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - ); - assert.deepEqual(spawned.args.slice(0, -1), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - ]); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'", - ); - assert.equal(spawned.options.detached, platform !== "win32"); - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - }, -); - -it.effect("launches an installed editor with platform-safe arguments", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "vscode", - cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - resolveExecutable: (command) => - command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal( - spawned.command, - [ - '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"', - '^"--goto^"', - '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', - ].join(" "), - ); - assert.deepEqual(spawned.args, []); - assert.equal(spawned.options.shell, true); - assert.equal(spawned.options.detached, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -for (const platform of ["darwin", "linux"] as const) { - it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const cursorPath = path.join(binDir, "cursor"); - yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(cursorPath, 0o755); - - const spawned: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - for (const cwd of [ - "/workspace with spaces", - "/workspace with spaces/src/index.ts", - "/workspace with spaces/src/index.ts:12", - "/workspace with spaces/src/index.ts:12:4", - ]) { - yield* launcher.launchEditor({ editor: "cursor", cwd }); - } - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { PATH: binDir }, - onSpawn: (command) => spawned.push(command), - }), - ), - ); - - assert.deepEqual( - spawned.map((command) => ({ command: command.command, args: command.args })), - [ - { command: "cursor", args: ["--classic", "/workspace with spaces"] }, - { command: "cursor", args: ["--classic", "/workspace with spaces/src/index.ts"] }, - { - command: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12"], - }, - { - command: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12:4"], - }, - ], - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} - -it.effect("launches Cursor in classic IDE mode through the Windows command shim", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "cursor.CMD"), "@echo off\r\n"); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "cursor", - cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - resolveExecutable: (command) => - command === "cursor" ? "C:\\Program Files\\Cursor\\bin\\cursor.CMD" : command, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal( - spawned.command, - [ - '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"', - '^"--classic^"', - '^"--goto^"', - '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', - ].join(" "), - ); - assert.deepEqual(spawned.args, []); - assert.equal(spawned.options.shell, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("opens a folder in File Explorer with Windows separators", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.EXE"), ""); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ editor: "file-manager", cwd: "C:/workspace/project" }); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.deepEqual(spawned.args, ["C:\\workspace\\project"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const openPath = path.join(binDir, "open"); - yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(openPath, 0o755); - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/workspace/media/linux-mini-v2.mp4", - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "darwin", - env: { PATH: binDir }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal(spawned.command, "open"); - assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); - assert.equal(spawned.options.detached, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("reveals a file in File Explorer through PowerShell on Windows", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); - // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows - // separators, which on the posix test filesystem is one file name. - const systemRoot = path.join(binDir, "system-root"); - const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; - yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); - yield* fileSystem.writeFileString(powerShellPath, ""); - - let spawned: ChildProcess.StandardCommand | undefined; - const kind = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "C:/workspace with spaces/media/author's clip.mp4", - reveal: true, - }); - return yield* launcher.resolveFileManagerRevealKind(); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.equal(kind, "file-explorer"); - assert.ok(spawned); - assert.equal(spawned.command, powerShellPath); - assert.deepEqual(spawned.args.slice(0, -1), [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - ]); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath 'C:\\workspace with spaces\\media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"') }", - ); - assert.equal(spawned.options.shell, false); - assert.equal(spawned.options.detached, false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -for (const { kind, folderVerb } of [ - { kind: "browser", folderVerb: "none" }, - { kind: "reveal", folderVerb: "none" }, - { kind: "reveal", folderVerb: "" }, - { kind: "reveal", folderVerb: "open" }, -] as const) { - it.live.skipIf(!windowsHost)( - `completes a ${kind} launch through real PowerShell after the launcher scope closes (folder verb: ${folderVerb || "empty"})`, - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); - const outputPath = NodePath.join(tempDir, "argv.txt"); - const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) }); - try { - const recorderPath = NodePath.join(tempDir, "recorder.cmd"); - const stagingPath = NodePath.join(tempDir, "argv.tmp"); - NodeFS.writeFileSync( - recorderPath, - `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`, - ); - const recorded = new Promise((resolve, reject) => { - watcher.on("change", () => { - if (NodeFS.existsSync(outputPath)) { - resolve(NodeFS.readFileSync(outputPath, "utf8").trim()); - } - }); - watcher.on("error", reject); - watcher.on("close", () => - reject(new Error("PowerShell did not run the recording stub")), - ); - }); - - const target = "C:/workspace with spaces/author's media/clip.mp4"; - yield* Effect.all( - [ - Effect.gen(function* () { - const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const recordingSpawner = ChildProcessSpawner.make((command) => { - assert.ok(ChildProcess.isStandardCommand(command)); - const recorderLiteral = `'${recorderPath.replaceAll("'", "''")}'`; - const source = Buffer.from(command.args.at(-1) ?? "", "base64") - .toString("utf16le") - .replace("'explorer.exe'", recorderLiteral); - // Control the association without changing the host registry or opening its file manager. - const handlers = - kind === "reveal" - ? `function Get-ItemProperty { [CmdletBinding()] param([string]$LiteralPath); if ($LiteralPath -ne 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell') { throw 'Unexpected registry path' }; @{ '(default)' = '${folderVerb}' } }; function Invoke-Item { param([string]$LiteralPath); Start-Process ${recorderLiteral} -ArgumentList ('"' + $LiteralPath + '"') }; ` - : ""; - return realSpawner.spawn( - ChildProcess.make( - command.command, - [ - ...command.args.slice(0, -1), - Buffer.from(handlers + source, "utf16le").toString("base64"), - ], - command.options, - ), - ); - }); - const launcher = yield* ExternalLauncher.make.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner), - ); - if (kind === "browser") { - yield* launcher.launchBrowser(recorderPath); - } else { - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: target, - reveal: true, - }); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - Effect.promise(() => - recorded.then((value) => { - assert.equal( - value, - kind === "browser" - ? "" - : folderVerb === "open" - ? `"C:\\workspace with spaces\\author's media"` - : `/select,"${target.replaceAll("/", "\\")}"`, - ); - }), - ), - ], - { concurrency: "unbounded" }, - ); - } finally { - watcher.close(); - // The recording stub's cmd.exe can still hold the directory for a - // moment after it publishes its output, which Windows reports as EPERM. - NodeFS.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); - } - }), - { timeout: 30_000 }, - ); -} - -it.effect("does not advertise reveal on Windows when PowerShell is missing", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); - - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return { - kind: yield* launcher.resolveFileManagerRevealKind(), - editors: yield* launcher.resolveAvailableEditors(), - }; - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { - PATH: binDir, - PATHEXT: ".COM;.EXE;.BAT;.CMD", - SYSTEMROOT: path.join(binDir, "missing-system-root"), - }, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.isUndefined(result.kind); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "reveals a WSL file in Windows File Explorer through its UNC path", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let spawned: ChildProcess.StandardCommand | undefined; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const kind = yield* launcher.resolveFileManagerRevealKind(); - const editors = yield* launcher.resolveAvailableEditors(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace [draft]/author's $media/clip|draft.mp4", - reveal: true, - }); - return { kind, editors }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.equal(result.kind, "file-explorer"); - assert.equal(result.editors.includes("file-manager"), true); - assert.ok(spawned); - assert.equal(spawned.command, "powershell.exe"); - assert.equal(spawned.options.detached, true); - const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; - const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); - assert.equal( - decodedCommand, - "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip|draft.mp4' + '\"') }", - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise reveal from WSL when interop PowerShell is missing", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const explorerPath = path.join(binDir, "explorer.exe"); - yield* fileSystem.writeFileString(explorerPath, ""); - yield* fileSystem.chmod(explorerPath, 0o755); - - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return { - kind: yield* launcher.resolveFileManagerRevealKind(), - editors: yield* launcher.resolveAvailableEditors(), - }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.isUndefined(result.kind); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "reveals through the Linux file manager when WSL lacks interop PowerShell", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const kind = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const revealKind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return revealKind; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "falls back to the Linux file manager when WSL lacks the Explorer bridge", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const result = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const editors = yield* launcher.resolveAvailableEditors(); - const kind = yield* launcher.resolveFileManagerRevealKind(); - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/home/t3/workspace/media/clip.mp4", - reveal: true, - }); - return { editors, kind }; - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - DISPLAY: ":0", - }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(result.editors.includes("file-manager"), true); - assert.equal(result.kind, "files"); - const launch = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(launch); - assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "falls back to opening the containing directory for WSL paths Explorer cannot select", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["explorer.exe", "powershell.exe"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: '/home/t3/work/clip "quoted".mp4', - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WSL_DISTRO_NAME: "Ubuntu-24.04", - WSL_INTEROP: "/run/WSL/1_interop", - }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - - assert.ok(spawned); - assert.equal(spawned.command, "powershell.exe"); - assert.equal(spawned.options.detached, true); - assert.equal(spawned.options.shell, false); - const source = Buffer.from(spawned.args.at(-1) ?? "", "base64").toString("utf16le"); - assert.equal( - source, - `Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work'`, - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Linux", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/workspace/media/linux-mini-v2.mp4", - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - spawnedCommands.push(command); - }, - spawnResult: (command) => - command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, - }), - ), - ); - - const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); - assert.ok(spawned); - assert.deepEqual(spawned.args, ["/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -for (const wsl of [false, true]) { - it.effect.skipIf(windowsHost)( - "selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"), - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime", "gdbus", ...(wsl ? ["explorer.exe"] : [])]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - const target = "/workspace/media/a 'quoted' \"clip\" #100% ? é\\name.mp4"; - const spawnedCommands: ChildProcess.StandardCommand[] = []; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ editor: "file-manager", cwd: target, reveal: true }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { - PATH: binDir, - WAYLAND_DISPLAY: "wayland-1", - ...(wsl - ? { WSL_DISTRO_NAME: "Ubuntu-24.04", WSL_INTEROP: "/run/WSL/1_interop" } - : {}), - }, - onSpawn: (command) => spawnedCommands.push(command), - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "com.thisisgm.flea.desktop\n" } - : undefined, - }), - ), - ); - - const reveal = spawnedCommands.find((command) => command.command === "gdbus"); - assert.ok(reveal); - assert.deepEqual(reveal.args, [ - "call", - "--session", - "--dest", - "org.freedesktop.FileManager1", - "--object-path", - "/org/freedesktop/FileManager1", - "--method", - "org.freedesktop.FileManager1.ShowItems", - "[\"file:///workspace/media/a%20'quoted'%20%22clip%22%20%23100%25%20%3F%20%C3%A9%5Cname.mp4\"]", - "", - ]); - assert.deepEqual( - spawnedCommands.filter((command) => command.command !== "xdg-mime"), - [reveal], - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} - -for (const scenario of ["rejected", "spawn-error", "stalled"] as const) { - it.effect.skipIf(windowsHost)("opens the containing directory when D-Bus is " + scenario, () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime", "gdbus"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const spawnedCommands: ChildProcess.StandardCommand[] = []; - const started = yield* Latch.make(); - const launch = Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ - editor: "file-manager", - cwd: "/workspace/media/linux-mini-v2.mp4", - reveal: true, - }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - spawnedCommands.push(command); - if (command.command === "gdbus") started.openUnsafe(); - }, - spawnResult: (command) => { - if (command.command === "xdg-mime") return { stdout: "org.gnome.Nautilus.desktop\n" }; - if (command.command !== "gdbus") return undefined; - if (scenario === "spawn-error") { - return { - spawnError: new PlatformError.PlatformError( - new PlatformError.SystemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - pathOrDescriptor: "gdbus", - }), - ), - }; - } - return { exitCode: 1, stall: scenario === "stalled" }; - }, - }), - ), - ); - if (scenario === "stalled") { - const fiber = yield* launch.pipe(Effect.forkChild); - yield* started.await; - yield* TestClock.adjust("1999 millis"); - assert.isUndefined(spawnedCommands.find((command) => command.command === "xdg-open")); - yield* TestClock.adjust("1 millis"); - yield* Fiber.join(fiber); - } else { - yield* launch; - } - - assert.deepEqual( - spawnedCommands - .filter((command) => command.command !== "xdg-mime") - .map((command) => command.command), - ["gdbus", "xdg-open"], - ); - assert.deepEqual(spawnedCommands.at(-1)?.args, ["/workspace/media"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -} - -it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager without a graphical session", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); - - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir } }))); - - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "advertises a Linux file manager when a directory handler is installed", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - let probe: ChildProcess.StandardCommand | undefined; - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - onSpawn: (command) => { - probe = command; - }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), - ); - - assert.equal(editors.includes("file-manager"), true); - assert.ok(probe); - assert.equal(probe.command, "xdg-mime"); - assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager without a directory handler", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), - }), - ), - ); - - assert.equal(editors.includes("file-manager"), false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager when the handler query fails", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } - - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => - command.command === "xdg-mime" - ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } - : undefined, - }), - ), + env: { PATH: binDir, DISPLAY: ":0" }, + extendEnv: false, + })), ); - - assert.equal(editors.includes("file-manager"), false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -// Runs on the live clock so the probe's real timeout fires. -it.live.skipIf(windowsHost)("a stalled handler probe drops only the file manager", () => +it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - for (const name of ["xdg-open", "xdg-mime", "code"]) { - const filePath = path.join(binDir, name); - yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); - yield* fileSystem.chmod(filePath, 0o755); - } + yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - const editors = yield* Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "vscode", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); }).pipe( Effect.provide( layerTest({ - platform: "linux", - env: { PATH: binDir, DISPLAY: ":0" }, - spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "code" ? "C:\\Program Files\\Microsoft VS Code\\bin\\code.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, }), ), ); - assert.equal(editors.includes("vscode"), true); - assert.equal(editors.includes("file-manager"), false); + assert.ok(spawned); + assert.equal( + spawned.command, + [ + '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"', + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ].join(" "), + ); + assert.deepEqual(spawned.args, []); + assert.equal(spawned.options.shell, true); + assert.equal(spawned.options.detached, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +for (const {} of ["darwin", "linux"] as const) { +} + +for (const {} of [ + { kind: "browser", folderVerb: "none" }, + { kind: "reveal", folderVerb: "none" }, + { kind: "reveal", folderVerb: "" }, + { kind: "reveal", folderVerb: "open" }, +] as const) { +} + it.effect.skipIf(windowsHost)( - "does not advertise a Linux file manager when xdg-mime is missing", + "reveals a WSL file in Windows File Explorer through its UNC path", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const xdgOpenPath = path.join(binDir, "xdg-open"); - yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); - yield* fileSystem.chmod(xdgOpenPath, 0o755); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } - const editors = yield* Effect.gen(function* () { + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace [draft]/author's $media/clip|draft.mp4", + reveal: true, + }); + return { kind, editors }; }).pipe( - Effect.provide(layerTest({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } })), + Effect.provide( + layerTest({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), ); - assert.equal(editors.includes("file-manager"), false); + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + assert.equal(spawned.command, "powershell.exe"); + assert.equal(spawned.options.detached, true); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media' } else { Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace [draft]\\author''s $media\\clip|draft.mp4' + '\"') }", + ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("discovers editors through the service API", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fileSystem.writeFileString(path.join(binDir, "code.CMD"), "@echo off\r\n"); - yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); +for (const wsl of [false, true]) { + it.effect.skipIf(windowsHost)( + "selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"), + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "gdbus", ...(wsl ? ["explorer.exe"] : [])]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + const target = "/workspace/media/a 'quoted' \"clip\" #100% ? é\\name.mp4"; + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "file-manager", cwd: target, reveal: true }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { + PATH: binDir, + WAYLAND_DISPLAY: "wayland-1", + ...(wsl + ? { WSL_DISTRO_NAME: "Ubuntu-24.04", WSL_INTEROP: "/run/WSL/1_interop" } + : {}), + }, + onSpawn: (command) => spawnedCommands.push(command), + spawnResult: (command) => + command.command === "xdg-mime" + ? { stdout: "com.thisisgm.flea.desktop\n" } + : undefined, + }), + ), + ); - const editors = yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - return yield* launcher.resolveAvailableEditors(); - }).pipe( - Effect.provide( - layerTest({ - platform: "win32", - env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }), - ), - ); + const reveal = spawnedCommands.find((command) => command.command === "gdbus"); + assert.ok(reveal); + assert.deepEqual(reveal.args, [ + "call", + "--session", + "--dest", + "org.freedesktop.FileManager1", + "--object-path", + "/org/freedesktop/FileManager1", + "--method", + "org.freedesktop.FileManager1.ShowItems", + "[\"file:///workspace/media/a%20'quoted'%20%22clip%22%20%23100%25%20%3F%20%C3%A9%5Cname.mp4\"]", + "", + ]); + assert.deepEqual( + spawnedCommands.filter((command) => command.command !== "xdg-mime"), + [reveal], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} - assert.equal(editors.includes("vscode"), true); - assert.equal(editors.includes("file-manager"), true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); +for (const {} of ["rejected", "spawn-error", "stalled"] as const) { +} -for (const { platform, installPath, editor, args } of [ +for (const {} of [ { platform: "darwin", installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide", @@ -1314,43 +410,6 @@ for (const { platform, installPath, editor, args } of [ args: ["/workspace with spaces/file.ts:12:4"], }, ] as const) { - it.effect.skipIf(windowsHost && platform !== "win32")( - `discovers and launches ${editor} outside PATH on ${platform}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editor installs-" }); - const executable = path.join(home, installPath); - yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); - yield* fs.writeFileString(executable, "#!/bin/sh\n"); - yield* fs.chmod(executable, 0o755); - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - assert.include(yield* launcher.resolveAvailableEditors(), editor); - yield* launcher.launchEditor({ editor, cwd: "/workspace with spaces/file.ts:12:4" }); - }).pipe( - Effect.provide( - layerTest({ - platform, - env: { HOME: home, LOCALAPPDATA: home, PATH: path.join(home, "empty") }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - assert.ok(spawned); - const shim = executable.endsWith(".cmd"); - assert.equal( - spawned.command, - shim ? [`^"${executable.replaceAll(" ", "^ ")}^"`, ...args].join(" ") : executable, - ); - assert.deepEqual(spawned.args, shim ? [] : args); - assert.equal(spawned.options.shell, shim); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); } for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) { @@ -1599,63 +658,6 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("memoizes editor discovery and refreshes after the cache window", () => { - let statCalls = 0; - const fileInfo = { type: "File" } as FileSystem.File.Info; - const layerLauncher = ExternalLauncher.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Effect.sync(() => { - statCalls += 1; - return fileInfo; - }), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), - ), - ), - ), - ); - - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - const first = yield* launcher.resolveAvailableEditors(); - assert.equal(first.includes("vscode"), true); - const statCallsAfterFirstScan = statCalls; - assert.isAbove(statCallsAfterFirstScan, 0); - - yield* TestClock.adjust("31 seconds"); - const second = yield* launcher.resolveAvailableEditors(); - assert.deepEqual([...second], [...first]); - assert.equal(statCalls, statCallsAfterFirstScan); - - yield* TestClock.adjust("30 seconds"); - yield* launcher.resolveAvailableEditors(); - assert.isAbove(statCalls, statCallsAfterFirstScan); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerLauncher, - Layer.succeed(HostProcess.Platform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-cache-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), - ), - TestClock.layer(), - ), - ), - ); -}); - it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; const release = Deferred.makeUnsafe(); @@ -1775,46 +777,6 @@ it.effect("starts a Windows command shim for unicode paths with cmd metacharacte }), ); -it.effect.skipIf(windowsHost)("passes line breaks through to editors started without a shell", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - const zed = path.join(binDir, "zed"); - yield* fileSystem.writeFileString(zed, "#!/bin/sh\n"); - yield* fileSystem.chmod(zed, 0o755); - let spawned: ChildProcess.StandardCommand | undefined; - yield* Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace/odd\nname.ts" }); - }).pipe( - Effect.provide( - layerTest({ - platform: "linux", - env: { PATH: binDir, HOME: binDir }, - onSpawn: (command) => { - spawned = command; - }, - }), - ), - ); - assert.ok(spawned); - assert.deepEqual(spawned.args, ["/workspace/odd\nname.ts"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("rejects unknown editors through the service API", () => - Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - const error = yield* launcher - .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) - .pipe(Effect.flip); - assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); - assert.equal(error.editor, "missing-editor"); - assert.equal(error.message, "Unknown editor: missing-editor"); - }).pipe(Effect.provide(layerTest({ platform: "linux", env: { PATH: "" } }))), -); - it.effect.skipIf(windowsHost)( "launches configured and system applications without file positions", () => diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index eb376ee5cedd..29d6dbd83659 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,24 +1,15 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; -import * as ChildProcess from "effect/process/ChildProcess"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ProcessRunner from "./processRunner.ts"; -import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; -import { makeProcessProbe, processProbeSource } from "./testUtils/processProbe.ts"; type ChildProcessCommand = { readonly command: string; @@ -85,50 +76,6 @@ const runWith = ); describe("runProcess", () => { - it.effect("collects stdout through an injected ChildProcessSpawner", () => - Effect.gen(function* () { - const spawner = makeSpawner((command) => - Effect.sync(() => { - expect(command.command).toBe("fake"); - expect(command.args).toEqual(["stdout-bytes", "32"]); - return makeHandle({ stdout: "x".repeat(32) }); - }), - ); - - const result = yield* runWith(spawner)({ - command: "fake", - args: ["stdout-bytes", "32"], - }); - - expect(result.code).toBe(0); - expect(result.stdout).toBe("x".repeat(32)); - expect(result.timedOut).toBe(false); - }), - ); - - it.effect("runs through the ProcessRunner service", () => { - const spawner = makeSpawner((command) => - Effect.sync(() => { - expect(command.command).toBe("fake"); - expect(command.args).toEqual(["--service"]); - return makeHandle({ stdout: "service ok" }); - }), - ); - const layer = ProcessRunner.layer.pipe( - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - - return Effect.gen(function* () { - const runner = yield* ProcessRunner.ProcessRunner; - const result = yield* runner.run({ - command: "fake", - args: ["--service"], - }); - - expect(result.stdout).toBe("service ok"); - }).pipe(Effect.provide(layer)); - }); - it.effect("resolves and escapes Windows command shims before spawning", () => { const spawner = makeSpawner((command) => Effect.sync(() => { @@ -170,44 +117,6 @@ describe("runProcess", () => { ); }); - it.effect("preserves resolved spawn context and cause", () => - Effect.gen(function* () { - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcessSpawner", - method: "spawn", - pathOrDescriptor: "/actual/fake", - }); - const spawner = makeSpawner(() => Effect.fail(cause)); - - const error = yield* runWith(spawner)({ - command: "fake", - args: ["--flag", "secret-token-value"], - cwd: "/logical", - spawnCwd: "/actual", - }).pipe(Effect.flip); - - expect(error._tag).toBe("ProcessSpawnError"); - if (error._tag !== "ProcessSpawnError") { - return expect.fail("Expected ProcessSpawnError"); - } - expect(error).toMatchObject({ - command: "fake", - argumentCount: 2, - cwd: "/logical", - spawnCwd: "/actual", - resolvedCommand: "fake", - resolvedArgumentCount: 2, - shell: false, - }); - expect(error.cause).toBe(cause); - expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); - expect(error).not.toHaveProperty("args"); - expect(error).not.toHaveProperty("resolvedArgs"); - expect(error.message).not.toContain("secret-token-value"); - }), - ); - it.effect("types spawn failures that the platform spawner throws synchronously", () => Effect.gen(function* () { // A non-directory entry on PATH makes node's spawn throw ENOTDIR @@ -354,204 +263,8 @@ describe("runProcess", () => { }), ); - it.effect("returns output for non-zero exit codes", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); - - const result = yield* runWith(spawner)({ - command: "fake", - args: ["stderr-exit", "boom", "2"], - }); - - expect(result.code).toBe(2); - expect(result.stderr).toBe("boom"); - }), - ); - - it.effect("fails on timeout", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => - Effect.succeed( - makeHandle({ - exitCode: Effect.never, - }), - ), - ); - const errorFiber = yield* runWith(spawner)({ - command: "fake", - args: ["sleep"], - cwd: "/logical", - spawnCwd: "/actual", - timeout: "50 millis", - }).pipe(Effect.flip, Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(errorFiber); - - expect(error._tag).toBe("ProcessTimeoutError"); - if (error._tag !== "ProcessTimeoutError") { - return expect.fail("Expected ProcessTimeoutError"); - } - expect(error).toMatchObject({ - command: "fake", - argumentCount: 1, - cwd: "/logical", - spawnCwd: "/actual", - timeoutMs: 50, - }); - expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); - }), - ); - - it.effect("kills a helper that ignores SIGTERM when its run times out", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-runner-" }); - const probe = yield* makeProcessProbe; - const helper = writeFakeCli({ - directory, - name: "stubborn", - source: `process.on("SIGTERM", () => {}); ${processProbeSource(probe.port)}`, - }); - const runner = yield* ProcessRunner.ProcessRunner; - const fiber = yield* runner - .run({ command: helper, args: [], timeout: "1 second" }) - .pipe(Effect.flip, Effect.forkScoped); - yield* probe.started; - - yield* TestClock.adjust("1 second"); - yield* probe.exited; - expect((yield* Fiber.join(fiber))._tag).toBe("ProcessTimeoutError"); - }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), - ); - - for (const wrapperExit of ["timeout", "success"] as const) { - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - `terminates descendants after wrapper ${wrapperExit}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-tree-" }); - const probe = yield* makeProcessProbe; - const helper = writeFakeCli({ - directory, - name: "wrapper", - source: ` - import { spawn } from "node:child_process"; - process.on("SIGTERM", () => {}); - const child = spawn(process.execPath, ["--input-type=module", "-e", ${JSON.stringify(` - ${processProbeSource(probe.port)} - probe.once("connect", () => process.send("ready")); - `)}], { stdio: ["ignore", "ignore", "ignore", "ipc"] }); - ${wrapperExit === "success" ? 'child.once("message", () => process.exit(0));' : 'child.on("exit", () => process.exit(0));'} - `, - }); - const runner = yield* ProcessRunner.ProcessRunner; - const fiber = yield* runner - .run({ command: helper, args: [], timeout: "1 second" }) - .pipe(Effect.result, Effect.forkScoped); - yield* probe.started; - - if (wrapperExit === "timeout") yield* TestClock.adjust("1 second"); - yield* probe.exited; - const result = yield* Fiber.join(fiber); - if (wrapperExit === "timeout") { - expect(result).toMatchObject({ - _tag: "Failure", - failure: { _tag: "ProcessTimeoutError" }, - }); - } else { - expect(result).toMatchObject({ - _tag: "Success", - success: { code: 0, timedOut: false }, - }); - } - }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))), - ); + for (const {} of ["timeout", "success"] as const) { } - - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "exits the backend and terminates stubborn helpers on graceful group shutdown", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-process-backend-" }); - const probe = yield* makeProcessProbe; - const helper = writeFakeCli({ - directory, - name: "helper", - source: `process.on("SIGTERM", () => {}); ${processProbeSource(probe.port)}`, - }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const path = yield* Path.Path; - const backend = yield* spawner.spawn( - ChildProcess.make( - process.execPath, - [ - "--experimental-strip-types", - "--input-type=module", - "-e", - ` - import * as Effect from "effect/Effect"; - import * as Layer from "effect/Layer"; - import * as NodeServices from "@effect/platform-node/NodeServices"; - import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; - import * as ProcessRunner from ${JSON.stringify(new URL("./processRunner.ts", import.meta.url).href)}; - NodeRuntime.runMain(Effect.gen(function* () { - const runner = yield* ProcessRunner.ProcessRunner; - yield* runner.run({ command: ${JSON.stringify(helper)}, args: [] }); - }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer))))); - `, - ], - { - detached: true, - forceKillAfter: "1 second", - cwd: yield* path.fromFileUrl(new URL("..", import.meta.url)), - }, - ), - ); - yield* probe.started; - - // Desktop allows five seconds for NodeRuntime to finish scope finalizers. - process.kill(-backend.pid, "SIGTERM"); - yield* Effect.all( - [backend.exitCode.pipe(Effect.timeout("4 seconds"), TestClock.withLive), probe.exited], - { concurrency: "unbounded" }, - ); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("returns a synthetic timed out result when timeoutBehavior is timedOutResult", () => - Effect.gen(function* () { - const spawner = makeSpawner(() => - Effect.succeed( - makeHandle({ - exitCode: Effect.never, - }), - ), - ); - const resultFiber = yield* runWith(spawner)({ - command: "fake", - args: ["sleep"], - timeout: "50 millis", - timeoutBehavior: "timedOutResult", - }).pipe(Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const result = yield* Fiber.join(resultFiber); - - expect(result).toMatchObject({ - stdout: "", - stderr: "", - code: null, - timedOut: true, - stdoutTruncated: false, - stderrTruncated: false, - }); - }), - ); }); describe("isWindowsCommandNotFound", () => { @@ -565,11 +278,3 @@ describe("isWindowsCommandNotFound", () => { }), ); }); - -describe("commandName", () => { - it("drops the directory from POSIX and Windows paths", () => { - expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude"); - expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd"); - expect(ProcessRunner.commandName("git")).toBe("git"); - }); -}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 002aebcac9c4..05640ec968d3 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -266,7 +266,7 @@ function finalizeRunProcess( ); } -export const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); +const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index b1f6f3e0ce7d..b9c72305b893 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -284,131 +284,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("groups Codex rollouts by cwd across date directories", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); - - const rollout = (year: string, month: string, day: string, name: string) => - path.join(codexHomePath, "sessions", year, month, day, name); - - yield* writeTranscript({ - filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), - contents: codexRolloutLine(workspace), - mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), - contents: codexRolloutLine(workspace), - mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), - contents: codexRolloutLine(otherWorkspace), - mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([ - { - path: otherWorkspace, - title: path.basename(otherWorkspace), - sources: ["codex"], - threadCount: 1, - lastActiveAt: "2026-02-09T11:00:00.000Z", - alreadyImported: false, - git: null, - }, - { - path: workspace, - title: path.basename(workspace), - sources: ["codex"], - threadCount: 2, - lastActiveAt: "2026-02-09T10:00:00.000Z", - alreadyImported: false, - git: null, - }, - ]); - }), - ); - - it.effect.each(["claudeAgent", "codex"] as const)( - "does not open a non-file %s transcript", - (source) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const transcriptPath = - source === "claudeAgent" - ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") - : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); - yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); - - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath === transcriptPath) transcriptOpenCount += 1; - return fileSystem.open(filePath, options); - }, - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect(result.candidates).toEqual([]); - expect(transcriptOpenCount).toBe(0); - }), - ); - - it.effect.each(["claudeAgent", "codex"] as const)( - "stops %s directory reads at the discovery operation budget", - (source) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const discoveryRoot = - source === "claudeAgent" - ? path.join(claudeHomePath, "projects") - : path.join(codexHomePath, "sessions"); - const emptyDirectories = Array.from( - { length: 20_001 }, - (_, index) => `empty-${index.toString().padStart(5, "0")}`, - ); - let directoryReadCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - if (directory === discoveryRoot) { - directoryReadCount += 1; - return Effect.succeed(emptyDirectories); - } - if (path.dirname(directory) === discoveryRoot) { - directoryReadCount += 1; - return Effect.succeed([]); - } - return fileSystem.readDirectory(directory, options); - }, - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect(result.candidates).toEqual([]); - expect(directoryReadCount).toBe(20_000); - }), - ); - it.effect("merges the same cwd seen by both agents and flags imported projects", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -487,38 +362,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("matches a persisted project alias to a transcript realpath", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const linkParent = yield* makeTempDir("t3code-scanner-links-"); - const workspaceAlias = path.join(linkParent, "workspace-alias"); - yield* fileSystem.symlink(workspace, workspaceAlias); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - importedWorkspaceRoots: [workspaceAlias], - }); - - expect(result.candidates[0]).toMatchObject({ - path: workspaceAlias, - projectId: ProjectId.make("project-1"), - alreadyImported: true, - git: null, - }); - }), - ); - it.effect("merges case aliases and preserves the persisted project path", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -765,34 +608,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("ignores invalid provider instances while scanning the remaining providers", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: 123 }, - }, - }, - }); - - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - }), - ); - it.effect("does not scan provider instances disabled by the envelope or config", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -834,25 +649,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("ignores relative working directories from malformed transcripts", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), - contents: claudeSessionLine(path.relative(path.resolve(), workspace)), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([]); - }), - ); - it.effect("drops candidates whose directory no longer exists", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -919,48 +715,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("excludes Codex scratch directories and Downloads", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const home = NodeOS.homedir(); - const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); - const scratchRoot = path.join(home, "Documents", "Codex", runId); - const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); - const downloads = path.join(home, "Downloads", runId); - const keep = yield* makeTempDir("t3code-workspace-keep-"); - yield* fileSystem.makeDirectory(scratch, { recursive: true }); - yield* fileSystem.makeDirectory(downloads, { recursive: true }); - yield* Effect.addFinalizer(() => - Effect.all([ - fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), - fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), - ]), - ); - - for (const [index, cwd] of [scratch, downloads, keep].entries()) { - yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "09", - "01", - `rollout-${index}.jsonl`, - ), - contents: codexRolloutLine(cwd), - mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), - }); - } - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); - }), - ); - it.effect("skips linked git worktrees and reports the origin of real checkouts", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1185,341 +939,81 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("finds session metadata after a first record larger than one chunk", () => + it.effect("skips malformed transcripts without failing the scan", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const workspace = yield* makeTempDir("t3code-workspace-"); - const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), - contents: `${history}${claudeSessionLine(workspace)}`, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - }), - ); - - it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); - const secondHome = yield* makeTempDir("t3code-metadata-second-"); - const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); - const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); - const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); - const directories = [ - path.join(claudeHomePath, "projects", "p"), - path.join(secondHome, "projects", "p"), - ]; - const templates = directories.map((directory) => path.join(directory, "template.jsonl")); - for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { - const record = encodeTranscriptRecord({ cwd: workspace }); - yield* writeTranscript({ - filePath: templates[index]!, - contents: - " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, - }); - } - const resolveFile = (filePath: string) => { - const index = directories.indexOf(path.dirname(filePath)); - return index === -1 ? filePath : templates[index]!; - }; - let reservedBytes = 0; - let opens = 0; - const requests: number[] = []; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - const index = directories.indexOf(directory); - return index === -1 - ? fileSystem.readDirectory(directory, options) - : Effect.succeed( - Array.from( - { length: index === 0 ? 32 : count - 32 }, - (_, item) => `session-${item}.jsonl`, - ), - ); - }, - stat: (filePath) => fileSystem.stat(resolveFile(filePath)), - open: (filePath, options) => { - if (!directories.includes(path.dirname(filePath))) - return fileSystem.open(filePath, options); - opens += 1; - return fileSystem.open(resolveFile(filePath), options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: (size: number) => { - reservedBytes += Number(size); - requests.push(Number(size)); - return file.readAlloc(size); - }, - })), - ); - }, - }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: secondHome }, - }, + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + git: null, }, - }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([ - firstWorkspace, - secondWorkspace, ]); - expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); - expect(result.truncated).toBe(count === 65 ? true : undefined); - expect(opens).toBe(64); - expect(reservedBytes).toBe(64 * 1024 * 1024); - expect(requests[0]).toBe(8 * 1024); - expect(Math.max(...requests)).toBe(8 * 1024); }), ); + }); - it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + describe("recentThreads for the conversation picker", () => { + it.effect("narrows to one session and rescans on refresh", () => Effect.gen(function* () { const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); - const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); - const workspace = yield* makeTempDir("t3code-short-metadata-project-"); - const directory = path.join(claudeHomePath, "projects", "p"); - const template = path.join(directory, "template.jsonl"); - const record = encodeTranscriptRecord({ cwd: workspace }); - const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; - const bytes = new TextEncoder().encode(contents); + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-picker-claude-"); + const codexHomePath = yield* makeTempDir("t3code-picker-codex-"); + const workspace = yield* makeTempDir("t3code-picker-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + const rollout = (sessionId: string, turns: number) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + ...Array.from({ length: turns }, (_, turn) => [ + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: `Prompt ${turn}` }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Answer ${turn}` }], + }, + }), + ]).flat(), + ].join("\n"); yield* writeTranscript({ - filePath: template, - contents, - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - let operations = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (target, options) => - target === directory - ? Effect.succeed( - Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), - ) - : fileSystem.readDirectory(target, options), - stat: (filePath) => - fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), - open: (filePath, options) => { - if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); - operations += 1; - let offset = 0; - return fileSystem.open(template, options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: () => - Effect.sync(() => { - operations += 1; - if (offset === bytes.length) return Option.none(); - return Option.some(bytes.subarray(offset, ++offset)); - }), - })), - ); - }, - }); - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, observedFileSystem), - ); - expect(operations).toBe(20_000); - expect(result.candidates[0]?.threadCount).toBe(50); - expect(result.truncated).toBe(count === 51 ? true : undefined); - }), - ); - - it.effect("bounds malformed metadata records without excluding another account", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); - const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); - const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); - const workspace = yield* makeTempDir("t3code-record-metadata-project-"); - const directory = path.join(claudeHomePath, "projects", "p"); - const template = path.join(directory, "template.jsonl"); - yield* writeTranscript({ - filePath: template, - contents: "x\n".repeat(1_001), - mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(secondHome, "projects", "p", "session.jsonl"), - contents: encodeTranscriptRecord({ cwd: workspace }), - mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), - }); - let malformedOpens = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (target, options) => - target === directory - ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) - : fileSystem.readDirectory(target, options), - stat: (filePath) => - fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), - open: (filePath, options) => { - if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); - malformedOpens += 1; - return fileSystem.open(template, options); - }, - }); - const result = yield* runScan({ - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: secondHome }, - }, - }, - }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); - expect(malformedOpens).toBe(100); - expect(result.truncated).toBe(true); - }), - ); - - it.effect.each([19_999, 20_000])( - "reports unfinished directory work for %s project directories", - (count) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); - const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); - const projectsDir = path.join(claudeHomePath, "projects"); - let reads = 0; - const observedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => { - if (directory === projectsDir) { - reads += 1; - return Effect.succeed( - Array.from({ length: count }, (_, index) => `project-${index}`), - ); - } - if (path.dirname(directory) === projectsDir) { - reads += 1; - return Effect.succeed([]); - } - return fileSystem.readDirectory(directory, options); - }, - }); - const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( - Effect.provideService(FileSystem.FileSystem, observedFileSystem), - ); - expect(reads).toBe(20_000); - expect(result.candidates).toEqual([]); - expect(result.truncated).toBe(count === 20_000 ? true : undefined); - }), - ); - - it.effect("skips malformed transcripts without failing the scan", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), - contents: "not json at all\n", - mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), - contents: `{"type":"summary"}\n`, - mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), - contents: claudeSessionLine(workspace), - mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), - }); - - const result = yield* runScan({ claudeHomePath, codexHomePath }); - - expect(result.candidates).toEqual([ - { - path: workspace, - title: path.basename(workspace), - sources: ["claudeAgent"], - threadCount: 1, - lastActiveAt: "2026-05-03T00:00:00.000Z", - alreadyImported: false, - git: null, - }, - ]); - }), - ); - - it.effect("returns an empty result when neither home directory exists", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const root = yield* makeTempDir("t3code-missing-homes-"); - - const result = yield* runScan({ - claudeHomePath: path.join(root, "no-claude"), - codexHomePath: path.join(root, "no-codex"), - }); - - expect(result.candidates).toEqual([]); - expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }), - ); - }); - - describe("recentThreads for the conversation picker", () => { - it.effect("narrows to one session and rescans on refresh", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-picker-claude-"); - const codexHomePath = yield* makeTempDir("t3code-picker-codex-"); - const workspace = yield* makeTempDir("t3code-picker-project-"); - const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); - const rollout = (sessionId: string, turns: number) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - ...Array.from({ length: turns }, (_, turn) => [ - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: `Prompt ${turn}` }, - }), - encodeTranscriptRecord({ - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: `Answer ${turn}` }], - }, - }), - ]).flat(), - ].join("\n"); - yield* writeTranscript({ - filePath: path.join(directory, "rollout-2026-08-24T10-00-00-session-long.jsonl"), - contents: rollout("session-long", 150), - mtimeMs: nowMs - 2_000, + filePath: path.join(directory, "rollout-2026-08-24T10-00-00-session-long.jsonl"), + contents: rollout("session-long", 150), + mtimeMs: nowMs - 2_000, }); yield* writeTranscript({ filePath: path.join(directory, "rollout-2026-08-24T11-00-00-session-short.jsonl"), @@ -1574,54 +1068,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { - it.effect.each([false, true])( - "counts terminal newlines correctly with record overflow=%s", - (overflow) => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); - const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); - const workspace = yield* makeTempDir("t3code-record-limit-project-"); - const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); - yield* writeTranscript({ - filePath: path.join(directory, "rollout-records.jsonl"), - contents: makeRecordLimitTranscript(workspace, overflow), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: path.join(directory, "rollout-older.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "older-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Older prompt" }, - }), - ].join("\n"), - mtimeMs: nowMs - 1_000, - }); - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - expect(outcomes.map((outcome) => outcome._tag)).toEqual( - overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], - ); - expect( - outcomes.flatMap((outcome) => - outcome._tag === "Importable" - ? outcome.thread.messages.map((message) => message.text) - : [], - ), - ).toEqual([overflow ? "Older prompt" : "First prompt"]); - }), - ); - it.effect("imports recent Claude and Codex sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1710,103 +1156,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports sessions started in a subfolder unless a nested project owns it", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const subfolder = path.join(workspace, "functions", "billing"); - const nestedProject = path.join(workspace, "nested"); - yield* fileSystem.makeDirectory(subfolder, { recursive: true }); - yield* fileSystem.makeDirectory(path.join(nestedProject, "src"), { recursive: true }); - - const writeSession = (cwd: string, sessionId: string, ageMs: number) => - writeTranscript({ - filePath: path.join(claudeHomePath, "projects", `-${sessionId}`, `${sessionId}.jsonl`), - contents: `${JSON.stringify({ - type: "user", - cwd, - sessionId, - timestamp: "2026-08-23T12:00:00.000Z", - message: { role: "user", content: "Fix the project" }, - })}\n`, - mtimeMs: nowMs - ageMs, - }); - yield* writeSession(workspace, "at-root", 1_000); - yield* writeSession(subfolder, "in-subfolder", 2_000); - yield* writeSession(path.join(subfolder, "deleted"), "in-deleted-folder", 3_000); - yield* writeSession(path.join(nestedProject, "src"), "in-nested-project", 4_000); - yield* writeSession(path.dirname(workspace), "above-root", 5_000); - - const sessionIds = (workspaceRoot: string) => - runRecentThreads({ - claudeHomePath, - codexHomePath, - workspaceRoot, - importedWorkspaceRoots: [workspace, nestedProject], - }).pipe(Effect.map((threads) => threads.map((thread) => thread.providerSessionId))); - - expect(yield* sessionIds(workspace)).toEqual([ - "at-root", - "in-subfolder", - "in-deleted-folder", - ]); - expect(yield* sessionIds(nestedProject)).toEqual(["in-nested-project"]); - }), - ); - - it.effect("imports history recorded with a case alias", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const workspaceAlias = path.join( - path.dirname(workspace), - path.basename(workspace).toUpperCase(), - ); - - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "user", - cwd: workspaceAlias, - sessionId: "case-session", - timestamp: "2026-08-24T10:00:00.000Z", - message: { role: "user", content: "Import case alias history" }, - }), - encodeTranscriptRecord({ - type: "assistant", - sessionId: "case-session", - timestamp: "2026-08-24T10:01:00.000Z", - message: { role: "assistant", content: "Imported" }, - }), - ].join("\n"), - mtimeMs: nowMs, - }); - - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), - }); - const threads = yield* runRecentThreads({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); - }), - ); - it.effect("keeps the provider instance that owns a custom session home", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1953,109 +1302,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("streams large transcripts across providers without hiding projects", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); - const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); - const workspace = yield* makeTempDir("t3code-budget-workspace-"); - const transcriptPaths = new Set(); - for (const [index, source] of [ - "codex", - "claudeAgent", - "codex", - "claudeAgent", - "codex", - ].entries()) { - const sessionId = `budget-session-${index}`; - const filePath = - source === "codex" - ? path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - `rollout-${sessionId}.jsonl`, - ) - : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); - const contents = - source === "codex" - ? [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Imported prompt" }, - }), - ].join("\n") - : encodeTranscriptRecord({ - type: "user", - cwd: workspace, - sessionId, - message: { content: "Imported prompt" }, - }); - transcriptPaths.add(filePath); - yield* writeTranscript({ - filePath, - contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), - mtimeMs: nowMs - index * 1_000, - }); - } - - const opens = new Map(); - let fullReadBytes = 0; - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - const count = (opens.get(filePath) ?? 0) + 1; - opens.set(filePath, count); - return fileSystem.open(filePath, options).pipe( - Effect.map((file) => - !transcriptPaths.has(filePath) || count === 1 - ? file - : { - ...file, - stat: file.stat, - readAlloc: (size: number) => - file.readAlloc(size).pipe( - Effect.tap((chunk) => - Effect.sync(() => { - if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; - }), - ), - ), - }, - ), - ); - }, - }); - const outcomes = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - const scan = yield* scanner.scan; - expect(scan.candidates[0]?.threadCount).toBe(5); - return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); - }).pipe( - Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })), - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - - expect(outcomes.map((outcome) => outcome._tag)).toEqual([ - "Importable", - "Importable", - "Importable", - "Importable", - "Importable", - ]); - expect(fullReadBytes).toBe(80 * 1024 * 1024); - }), - ); - it.effect("skips excessive records without blocking an older valid transcript", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -2279,595 +1525,47 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports visible history from a transcript with an oversized tool record", () => + it.effect("imports long Claude tool history with metadata from discarded records", () => Effect.gen(function* () { const path = yield* Path.Path; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcript = `${[ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "large-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Import this large session" }, - }), - ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd( - 16 * 1024 * 1024 + 1, - " ", - )}`; - yield* writeTranscript({ - filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), - contents: transcript, - mtimeMs: nowMs, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(outcomes).toMatchObject([ - { - _tag: "Importable", - thread: { - providerSessionId: "large-session", - messages: [{ role: "user", text: "Import this large session" }], - }, - }, - ]); - }), - ); - - it.effect("imports long Claude tool history with metadata from discarded records", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-long-claude-"); - const codexHomePath = yield* makeTempDir("t3code-long-codex-"); - const workspace = yield* makeTempDir("t3code-long-workspace-"); - const record = (fields: Record) => - encodeTranscriptRecord({ sessionId: "first-session", ...fields }); - const toolResult = record({ - type: "user", - cwd: path.join(workspace, "later-cwd"), - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], - }, - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "long-session.jsonl"), - contents: [ - record({ type: "attachment", cwd: workspace, aiTitle: "Old title" }), - record({ type: "user", message: { role: "user", content: "First prompt" } }), - record({ - type: "assistant", - message: { role: "assistant", model: "claude-opus-5-5", content: "First reply" }, - }), - // Small tool records exceed the real 32 MiB selected-history budget in aggregate. - ...Array.from({ length: 20_000 }, () => toolResult), - record({ type: "user", message: { role: "user", content: "Latest prompt" } }), - record({ - type: "assistant", - message: { - role: "assistant", - model: "claude-sonnet-5-5", - content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }], - }, - }), - record({ type: "custom-title", customTitle: "Renamed conversation" }), - record({ type: "ai-title", aiTitle: "Newest title" }), - record({ type: "attachment", sessionId: "latest-session" }), - ].join("\n"), - mtimeMs: nowMs, - }); - - const threads = yield* runRecentThreads({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(threads).toMatchObject([ - { - providerSessionId: "latest-session", - title: "Renamed conversation", - model: "claude-sonnet-5-5", - messages: [ - { role: "user", text: "First prompt" }, - { role: "assistant", text: "First reply" }, - { role: "user", text: "Latest prompt" }, - ], - }, - ]); - }), - ); - - it.effect("excludes hidden Claude records from the history budget and session metadata", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-hidden-claude-"); - const codexHomePath = yield* makeTempDir("t3code-hidden-codex-"); - const workspace = yield* makeTempDir("t3code-hidden-workspace-"); - const message = (role: "user" | "assistant", text: string) => - encodeTranscriptRecord({ - sessionId: "visible-session", - cwd: workspace, - type: role, - message: { role, content: text }, - }); - const flags = ["isSidechain", "isMeta", "isCompactSummary"] as const; - // Each hidden reply selects about 2 MiB; together they exceed the real 32 MiB budget. - const hiddenReply = (flag: (typeof flags)[number]) => - encodeTranscriptRecord({ - sessionId: "hidden-session", - aiTitle: "Hidden title", - customTitle: "Hidden rename", - type: "assistant", - [flag]: true, - message: { role: "assistant", model: "hidden-model", content: "x".repeat(1024 * 1024) }, - }); - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", "-slug", "hidden-session.jsonl"), - contents: [ - message("user", "First prompt"), - message("assistant", "First reply"), - ...Array.from({ length: 20 }, (_, index) => hiddenReply(flags[index % flags.length]!)), - message("user", "Latest prompt"), - hiddenReply("isSidechain"), - ].join("\n"), - mtimeMs: nowMs, - }); - - const threads = yield* runRecentThreads({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(threads).toMatchObject([ - { - providerSessionId: "visible-session", - title: "First prompt", - model: null, - messages: [ - { role: "user", text: "First prompt" }, - { role: "assistant", text: "First reply" }, - { role: "user", text: "Latest prompt" }, - ], - }, - ]); - }), - ); - - it.effect("reports stat, read, and parse failures as skipped", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const missingPath = path.join(codexHomePath, "missing.jsonl"); - const transcriptPaths = { - stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), - read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), - parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), - }; - const transcriptContents = (sessionId: string) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Import this session" }, - }), - ].join("\n"); - - yield* writeTranscript({ - filePath: transcriptPaths.stat, - contents: transcriptContents("stat-session"), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: transcriptPaths.read, - contents: transcriptContents("read-session"), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: transcriptPaths.parse, - contents: encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "parse-session", cwd: workspace }, - }), - mtimeMs: nowMs, - }); - - let statCount = 0; - let readOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => { - if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); - statCount += 1; - return fileSystem.stat(statCount === 1 ? filePath : missingPath); - }, - open: (filePath, options) => { - if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); - readOpenCount += 1; - return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); - }), - ); - - it.effect("does not reopen a transcript that becomes a non-file after discovery", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const nonFilePath = yield* makeTempDir("t3code-non-file-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-changed.jsonl", - ); - yield* writeTranscript({ - filePath: transcriptPath, - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "changed-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import this session" }, - }), - ].join("\n"), - mtimeMs: nowMs, - }); - - let transcriptStatCount = 0; - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => { - if (filePath !== transcriptPath) return fileSystem.stat(filePath); - transcriptStatCount += 1; - return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); - }, - open: (filePath, options) => { - if (filePath === transcriptPath) transcriptOpenCount += 1; - return fileSystem.open(filePath, options); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptStatCount).toBe(2); - expect(transcriptOpenCount).toBe(1); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("does not import a transcript dated after the current time", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-future.jsonl", - ), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "future-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Future work" }, - }), - ].join("\n"), - // Node's BigInt stat (which the Effect file system now uses) floors - // sub-millisecond precision, so a one-millisecond offset can round - // back to `nowMs`; use a full second to stay clear of the clock. - mtimeMs: nowMs + 1_000, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }); - - expect(outcomes).toEqual([]); - }), - ); - - it.effect("skips growth during reading without exceeding the reserved bytes", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-growing.jsonl", - ); - const contents = [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "growing-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import a changing file" }, - }), - ].join("\n"); - yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); - let transcriptOpenCount = 0; - let fullReadBytes = 0; - let grew = false; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath !== transcriptPath) return fileSystem.open(filePath, options); - transcriptOpenCount += 1; - if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); - return fileSystem.open(filePath, options).pipe( - Effect.map((file) => ({ - ...file, - stat: file.stat, - readAlloc: (size: number) => - file.readAlloc(size).pipe( - Effect.tap((chunk) => - Effect.gen(function* () { - if (chunk._tag === "None") return; - fullReadBytes += chunk.value.byteLength; - if (!grew) { - grew = true; - yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); - } - }), - ), - ), - })), - ); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptOpenCount).toBe(2); - expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("skips a transcript that shrinks after its size check", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const transcriptPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-shrinking.jsonl", - ); - const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); - const contents = [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "shrinking-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import a changing file" }, - }), - ].join("\n"); - yield* writeTranscript({ - filePath: transcriptPath, - contents: `${contents}\n${"padding".repeat(100)}`, - mtimeMs: nowMs, - }); - yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); - - let transcriptOpenCount = 0; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (filePath !== transcriptPath) return fileSystem.open(filePath, options); - transcriptOpenCount += 1; - return fileSystem.open( - transcriptOpenCount === 1 ? transcriptPath : shrunkPath, - options, - ); - }, - }); - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: workspace, - }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - - expect(transcriptOpenCount).toBe(2); - expect(outcomes).toEqual([{ _tag: "Skipped" }]); - }), - ); - - it.effect("does not read the second transcript when the consumer takes one thread", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const workspace = yield* makeTempDir("t3code-workspace-"); - const makeCodexTranscript = (sessionId: string, text: string) => - [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: sessionId, cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: text }, - }), - ].join("\n"); - const olderPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "23", - "rollout-older.jsonl", - ); - const newerPath = path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-newer.jsonl", - ); - yield* writeTranscript({ - filePath: olderPath, - contents: makeCodexTranscript("older-session", "Older prompt"), - mtimeMs: nowMs - 1_000, - }); - yield* writeTranscript({ - filePath: newerPath, - contents: makeCodexTranscript("newer-session", "Newer prompt"), - mtimeMs: nowMs, - }); - - const openCounts = new Map(); - const contentReads: Array = []; - const trackedPaths = new Set([olderPath, newerPath]); - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - open: (filePath, options) => { - if (trackedPaths.has(filePath)) { - const count = (openCounts.get(filePath) ?? 0) + 1; - openCounts.set(filePath, count); - if (count === 2) contentReads.push(filePath); - } - return fileSystem.open(filePath, options); - }, - }); - - const threads = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - return yield* scanner.recentThreads(workspace).pipe( - Stream.take(1), - Stream.runCollect, - Effect.map((items) => Array.from(items)), - ); - }).pipe( - Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })), - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect( - threads.flatMap((outcome) => - outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], - ), - ).toEqual(["newer-session"]); - expect(contentReads).toEqual([newerPath]); - expect(openCounts.get(olderPath)).toBe(1); - }), - ); - - it.effect("does not import sessions from a T3-managed worktree", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); - const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); - yield* fileSystem.makeDirectory(workspace, { recursive: true }); - + const claudeHomePath = yield* makeTempDir("t3code-long-claude-"); + const codexHomePath = yield* makeTempDir("t3code-long-codex-"); + const workspace = yield* makeTempDir("t3code-long-workspace-"); + const record = (fields: Record) => + encodeTranscriptRecord({ sessionId: "first-session", ...fields }); + const toolResult = record({ + type: "user", + cwd: path.join(workspace, "later-cwd"), + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], + }, + }); yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - "rollout-managed.jsonl", - ), + filePath: path.join(claudeHomePath, "projects", "-slug", "long-session.jsonl"), contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "managed-session", cwd: workspace }, + record({ type: "attachment", cwd: workspace, aiTitle: "Old title" }), + record({ type: "user", message: { role: "user", content: "First prompt" } }), + record({ + type: "assistant", + message: { role: "assistant", model: "claude-opus-5-5", content: "First reply" }, }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Do not import this session" }, + // Small tool records exceed the real 32 MiB selected-history budget in aggregate. + ...Array.from({ length: 20_000 }, () => toolResult), + record({ type: "user", message: { role: "user", content: "Latest prompt" } }), + record({ + type: "assistant", + message: { + role: "assistant", + model: "claude-sonnet-5-5", + content: [{ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }], + }, }), + record({ type: "custom-title", customTitle: "Renamed conversation" }), + record({ type: "ai-title", aiTitle: "Newest title" }), + record({ type: "attachment", sessionId: "latest-session" }), ].join("\n"), mtimeMs: nowMs, }); @@ -2875,60 +1573,92 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const threads = yield* runRecentThreads({ claudeHomePath, codexHomePath, - configBaseDir, workspaceRoot: workspace, }); - expect(threads).toEqual([]); + expect(threads).toMatchObject([ + { + providerSessionId: "latest-session", + title: "Renamed conversation", + model: "claude-sonnet-5-5", + messages: [ + { role: "user", text: "First prompt" }, + { role: "assistant", text: "First reply" }, + { role: "user", text: "Latest prompt" }, + ], + }, + ]); }), ); - it.effect("uses one deterministic provider instance for a shared session home", () => + it.effect("skips growth during reading without exceeding the reserved bytes", () => Effect.gen(function* () { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const sharedHome = yield* makeTempDir("t3code-codex-shared-"); const workspace = yield* makeTempDir("t3code-workspace-"); - - yield* writeTranscript({ - filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), - contents: [ - encodeTranscriptRecord({ - type: "session_meta", - payload: { id: "shared-session", cwd: workspace }, - }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: "Use the shared session" }, - }), - ].join("\n"), - mtimeMs: nowMs, + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: number) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, }); - const threads = yield* runRecentThreads({ + const outcomes = yield* runRecentThreadOutcomes({ claudeHomePath, codexHomePath, workspaceRoot: workspace, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - [ProviderInstanceId.make("codex-work")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: sharedHome }, - }, - }, - }); + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); - expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); }), ); @@ -2976,88 +1706,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); }), ); - - it.effect("keeps a second account when the first has 5000 newer files", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); - const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); - const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); - const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); - const oldTranscript = path.join(oldDirectory, "old.jsonl"); - const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); - - yield* writeTranscript({ - filePath: oldTranscript, - contents: encodeTranscriptRecord({ - type: "user", - cwd: oldWorkspace, - sessionId: "old-session", - message: { role: "user", content: "Old work" }, - }), - mtimeMs: nowMs, - }); - yield* writeTranscript({ - filePath: path.join(recentDirectory, "recent.jsonl"), - contents: encodeTranscriptRecord({ - type: "user", - cwd: recentWorkspace, - sessionId: "recent-session", - message: { role: "user", content: "Recent work" }, - }), - mtimeMs: nowMs - 1_000, - }); - - const simulatedOldTranscripts = Array.from( - { length: 5_000 }, - (_, index) => `old-${index}.jsonl`, - ); - const resolveTranscript = (filePath: string) => - path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") - ? oldTranscript - : filePath; - const simulatedFileSystem = FileSystem.FileSystem.of({ - ...fileSystem, - readDirectory: (directory, options) => - directory === oldDirectory - ? Effect.succeed(simulatedOldTranscripts) - : fileSystem.readDirectory(directory, options), - stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), - open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), - }); - - const input = { - claudeHomePath, - codexHomePath, - providerInstances: { - [ProviderInstanceId.make("claude-work")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: recentHome }, - }, - }, - }; - const threads = yield* Effect.gen(function* () { - const scanner = yield* AgentSessionScanner.AgentSessionScanner; - const scan = yield* scanner.scan; - expect(scan.truncated).toBe(true); - return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); - }).pipe( - Effect.provide(layerScannerTest(input)), - Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), - ); - - expect( - threads.flatMap((outcome) => - outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], - ), - ).toEqual(["recent-session"]); - }), - ); }); }); @@ -3592,53 +2240,6 @@ describe("parseAgentSessionTranscript", () => { ]); }); - it("preserves a canonical Codex event that starts with context markup", () => { - const prompt = - "\n/tmp/project\n\n\nCreate a useful project."; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { - type: "user_message", - message: prompt, - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.title).toBe("Create a useful project."); - expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); - }); - - it("preserves a Codex request heading in a canonical event", () => { - const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { - type: "user_message", - message: prompt, - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.title).toBe("Fix the visible bug"); - expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); - }); - it("titles a Codex event after the request that follows injected setup blocks", () => { const prompt = [ "\nGitHub\n", @@ -3664,26 +2265,6 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); }); - it("keeps context markup quoted inside visible Codex user text", () => { - const quoted = - "Do not remove this example:\n\n/tmp/example\n"; - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), - encodeTranscriptRecord({ - type: "event_msg", - payload: { type: "user_message", message: quoted }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), - }); - - expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); - }); - it("skips sessions without a visible user message", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: JSON.stringify({ @@ -3698,32 +2279,4 @@ describe("parseAgentSessionTranscript", () => { expect(thread).toBeNull(); }); - - it("keeps the first prompt when later assistant output exceeds the message limit", () => { - const transcript = [ - encodeTranscriptRecord({ - type: "user", - sessionId: "claude-session", - message: { role: "user", content: "Keep this prompt" }, - }), - ...Array.from({ length: 250 }, (_, index) => - encodeTranscriptRecord({ - type: "assistant", - message: { role: "assistant", content: `Assistant update ${index}` }, - }), - ), - ].join("\n"); - - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: transcript, - source: "claudeAgent", - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), - }); - - expect(thread?.messages).toHaveLength(200); - expect(thread?.messages[0]?.text).toBe("Keep this prompt"); - expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); - }); }); diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 5887231aab84..4ce4724823c9 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,12 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - CommandId, - GitCommandError, - ProjectId, - ThreadId, - VcsProcessSpawnError, -} from "@t3tools/contracts"; +import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -49,6 +43,7 @@ const layerEnrichment = ProjectEnrichmentService.layer.pipe( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), @@ -116,15 +111,6 @@ const requireRoot = Effect.gen(function* () { return Option.getOrThrow(yield* scratch.scratchRoot); }); -it.effect("offers a Scratch folder under the data dir when it is outside a checkout", () => - withScratch(({ baseDir }) => - Effect.gen(function* () { - const path = yield* Path.Path; - assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch")); - }), - ), -); - it.effect("offers nothing when the data dir sits inside a Git checkout", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -549,36 +535,3 @@ it.effect("removes the folder when the create is cancelled before the project ex ); }), ); - -it.effect("removes the folder when the repository cannot be made", () => - withScratch( - ({ baseDir }) => - Effect.gen(function* () { - const folders = yield* ManagedProjectFolders.ManagedProjectFolders; - const projects = yield* ProjectService.ProjectService; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const failure = yield* Effect.flip(folders.createNamedProject({ name: "Broken" })); - - assert.equal(failure._tag, "NamedProjectFolderError"); - assert.equal(failure.message, "Failed to create the project folder."); - assert.deepEqual(yield* fileSystem.readDirectory(path.resolve(baseDir, "projects")), []); - assert.deepEqual((yield* projects.snapshot).projects, []); - }), - { - git: Layer.mock(GitVcsDriver.GitVcsDriver)({ - readConfigValue: () => Effect.succeed(null), - execute: (input) => - Effect.fail( - new GitCommandError({ - operation: input.operation, - command: "git", - cwd: input.cwd, - detail: "git is broken", - }), - ), - }), - }, - ), -); diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 7a0238159a74..492d1c43419a 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -3,12 +3,8 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; -import { TestClock } from "effect/testing"; - -import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -48,108 +44,6 @@ const layer = ( Layer.provide(metadataLayer), ); -it.effect("preserves either enrichment field when the other resolver fails", () => - Effect.gen(function* () { - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - workspaceRoot === "/repo-fails" - ? Effect.die("repository resolver failed") - : Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - workspaceRoot === "/favicon-fails" - ? Effect.fail( - new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot, - cause: "favicon resolver failed", - }), - ) - : Effect.succeed(`${workspaceRoot}/favicon.svg`), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.request("/favicon-fails"); - yield* service.request("/repo-fails"); - - const faviconFailure = yield* waitForAvailable( - service, - "/favicon-fails", - (value) => value.repositoryIdentity !== null, - ); - assert.equal( - faviconFailure.repositoryIdentity?.canonicalKey, - "example.test/v1/favicon-fails", - ); - assert.isNull(faviconFailure.faviconPath); - - const repositoryFailure = yield* waitForAvailable( - service, - "/repo-fails", - (value) => value.faviconPath !== null, - ); - assert.isNull(repositoryFailure.repositoryIdentity); - assert.equal(repositoryFailure.faviconPath, "/repo-fails/favicon.svg"); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - -it.effect("does not warn about favicons for workspace roots that no longer exist", () => - Effect.gen(function* () { - const warnings: Array = []; - const logger = Logger.make(({ message }) => { - warnings.push(message); - }); - const metadataLayer = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Effect.fail( - workspaceRoot === "/missing" - ? new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "normalize-workspace", - workspaceRoot, - cause: new WorkspacePaths.WorkspaceRootNotExistsError({ - workspaceRoot, - normalizedWorkspaceRoot: workspaceRoot, - }), - }) - : new ProjectFaviconResolver.ProjectFaviconResolutionError({ - operation: "stat-candidate", - workspaceRoot, - cause: "favicon resolver failed", - }), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.request("/missing"); - yield* service.request("/broken"); - - for (let attempt = 0; attempt < 100 && warnings.length === 0; attempt += 1) { - yield* Effect.yieldNow; - } - - assert.lengthOf(warnings, 1); - assert.nestedPropertyVal(warnings[0], "[1].workspaceRoot", "/broken"); - }).pipe( - Effect.provide( - layer(metadataLayer, { concurrency: 1 }).pipe( - Layer.provide(Logger.layer([logger], { mergeWithExisting: false })), - ), - ), - ); - }), -); - it.effect("publishes repository completion while favicon enrichment is still pending", () => Effect.gen(function* () { const releaseFavicon = yield* Deferred.make(); @@ -158,6 +52,7 @@ it.effect("publishes repository completion while favicon enrichment is still pen resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Deferred.await(releaseFavicon).pipe(Effect.as(`${workspaceRoot}/favicon.svg`)), }), @@ -186,6 +81,7 @@ it.effect("keeps repository workers available when every favicon worker is hung" resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.gen(function* () { const started = yield* Ref.updateAndGet(faviconStarts, (count) => count + 1); @@ -224,45 +120,6 @@ it.effect("keeps repository workers available when every favicon worker is hung" }), ); -it.effect("getAvailable returns immediately while repository identity is still unresolved", () => - Effect.gen(function* () { - const repositoryStarted = yield* Deferred.make(); - const releaseRepository = yield* Deferred.make(); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Deferred.succeed(repositoryStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRepository)), - Effect.as(identity(workspaceRoot)), - ), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - - const immediate = yield* service.getAvailable("/pending-identity"); - assert.isNull(immediate.repositoryIdentity); - assert.isNull(immediate.faviconPath); - assert.isFalse(immediate.repositoryIdentityResolved); - - yield* Deferred.await(repositoryStarted); - - yield* Deferred.succeed(releaseRepository, undefined); - const resolved = yield* waitForAvailable( - service, - "/pending-identity", - (value) => value.repositoryIdentity !== null, - ); - assert.equal(resolved.repositoryIdentity?.canonicalKey, "example.test/v1/pending-identity"); - assert.isTrue(resolved.repositoryIdentityResolved); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - it.effect( "reports successful cached null as resolved while cold and failed lookups stay unresolved", () => @@ -285,6 +142,7 @@ it.effect( }, }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ); @@ -344,6 +202,7 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -394,88 +253,3 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r ); }), ); - -it.effect("rescans a favicon only after 15 minutes", () => - Effect.gen(function* () { - const faviconScans = yield* Ref.make(0); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Ref.updateAndGet(faviconScans, (count) => count + 1).pipe( - Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.getAvailable("/repo"); - yield* waitForAvailable( - service, - "/repo", - (value) => value.faviconPath === "/repo/favicon-1.svg", - ); - - for (let minute = 1; minute < 15; minute += 1) { - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/repo"); - } - assert.equal(yield* Ref.get(faviconScans), 1); - - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/repo"); - yield* waitForAvailable( - service, - "/repo", - (value) => value.faviconPath === "/repo/favicon-2.svg", - ); - assert.equal(yield* Ref.get(faviconScans), 2); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); - -it.effect( - "follows repository identity changes within a minute without rescanning the favicon", - () => - Effect.gen(function* () { - const remoteVersion = yield* Ref.make(0); - const faviconScans = yield* Ref.make(0); - const layerMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Ref.get(remoteVersion).pipe( - Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))), - ), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectEnrichment.ProjectEnrichmentService; - yield* service.getAvailable("/folder"); - yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved); - assert.equal((yield* service.peek("/folder")).repositoryIdentity, null); - - for (const version of [1, 2]) { - yield* Ref.set(remoteVersion, version); - yield* TestClock.adjust("1 minute"); - yield* service.getAvailable("/folder"); - const available = yield* waitForAvailable( - service, - "/folder", - (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`, - ); - assert.equal( - available.repositoryIdentity?.canonicalKey, - `example.test/v${version}/folder`, - ); - } - assert.equal(yield* Ref.get(faviconScans), 1); - }).pipe(Effect.provide(layer(layerMetadata))); - }), -); diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index efb510e37ef2..1592204816fe 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -283,6 +283,8 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( [ Cache.invalidate(repositoryIdentityCache, workspaceRoot), Cache.invalidate(faviconCache, workspaceRoot), + // The resolver keeps its own misses, which would outlive a clone filling the folder. + faviconResolver.invalidate(workspaceRoot), ], { concurrency: "unbounded", discard: true }, ), diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 1d5bf9441d0d..5877662f46bc 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,11 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -51,27 +49,20 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { - it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + it.effect("finds a favicon added after a cached miss once the workspace is invalidated", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; const path = yield* Path.Path; const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "public/favicon.svg", "public"); - const resolved = yield* resolver.resolvePath(cwd); - expect(resolved?.endsWith(path.join("public", "favicon.svg"))).toBe(true); - - yield* writeTextFile(cwd, "favicon.svg", "root"); - - for (const _attempt of [1, 2, 3]) { - expect(yield* resolver.resolvePath(cwd)).toBe(resolved); - } - - yield* TestClock.adjust(Duration.minutes(11)); + // A clone's project exists while its folder is still empty. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + yield* writeTextFile(cwd, "favicon.ico", "icon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); - expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); - expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); - }).pipe(Effect.provide(TestClock.layer())), + yield* resolver.invalidate(cwd); + expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.ico")); + }), ); it.effect("falls back at once when a cached favicon is deleted", () => @@ -90,35 +81,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - - yield* TestClock.adjust(Duration.minutes(2)); - - expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("prefers well-known favicon files", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("favicon.svg"); - }), - ); - it.effect("prefers a t3.json iconPath over well-known files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -178,20 +140,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("falls back to well-known files when the t3.json iconPath does not exist", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }'); - yield* writeTextFile(cwd, "favicon.svg", "favicon"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toContain("favicon.svg"); - }), - ); - it.effect("ignores invalid t3.json files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -259,42 +207,7 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("resolves object-literal icon metadata when href precedes rel", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile( - cwd, - "src/root.tsx", - `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`, - ); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); - - it.effect("resolves object-literal icon metadata alongside nested objects", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile( - cwd, - "src/root.tsx", - `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`, - ); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); - + // regression: #5538 favicon resolution pinned the event loop it.effect("skips icon metadata without an href and keeps scanning", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -313,110 +226,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("scans large icon sources without an icon in reasonable time", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - const filler = `

${"pokopia companion guide ".repeat(24)}

\n`; - yield* writeTextFile( - cwd, - "index.html", - `guide\n${filler.repeat(1200)}`, - ); - - const startedAt = performance.now(); - const resolved = yield* resolver.resolvePath(cwd); - const elapsedMs = performance.now() - startedAt; - - expect(resolved).toBeNull(); - expect(elapsedMs).toBeLessThan(5_000); - }), - ); - - it.effect("returns null when no icon is present", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).toBeNull(); - }), - ); - - it.effect("preserves workspace normalization context", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - const missingCwd = `${cwd}/missing`; - - const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "normalize-workspace", - workspaceRoot: missingCwd, - }); - expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError); - }), - ); - - it.effect("preserves non-missing candidate stat failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const faviconPath = path.join(cwd, "favicon.svg"); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: faviconPath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "stat-candidate", - workspaceRoot: cwd, - relativePath: "favicon.svg", - absolutePath: faviconPath, - }); - expect(error.cause).toBe(cause); - }), - ); - - it.effect("probes well-known candidates concurrently", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* makeTempDir; - let inFlight = 0; - let peakInFlight = 0; - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - Effect.suspend(() => { - inFlight += 1; - peakInFlight = Math.max(peakInFlight, inFlight); - return Effect.yieldNow.pipe(Effect.andThen(fileSystem.stat(filePath))); - }).pipe(Effect.ensuring(Effect.sync(() => (inFlight -= 1)))), - }), - ); - - expect(yield* resolver.resolvePath(cwd)).toBeNull(); - expect(peakInFlight).toBeGreaterThan(1); - }), - ); - it.effect("returns a found favicon without waiting for lower-ranked probes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -435,70 +244,6 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { }), ); - it.effect("ignores a failing candidate that ranks below a found favicon", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "favicon.svg", "root"); - const failingPath = path.join(cwd, "favicon.ico"); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - stat: (filePath) => - filePath === failingPath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: failingPath, - }), - ) - : fileSystem.stat(filePath), - }), - ); - - expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); - }), - ); - - it.effect("preserves icon source read failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const sourcePath = path.join(cwd, "index.html"); - yield* writeTextFile(cwd, "index.html", ''); - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - pathOrDescriptor: sourcePath, - }); - const resolver = yield* makeResolverWithFileSystem( - FileSystem.FileSystem.of({ - ...fileSystem, - readFileString: (filePath, options) => - filePath === sourcePath - ? Effect.fail(cause) - : fileSystem.readFileString(filePath, options), - }), - ); - - const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ProjectFaviconResolutionError", - operation: "read-source", - workspaceRoot: cwd, - relativePath: "index.html", - absolutePath: sourcePath, - }); - expect(error.cause).toBe(cause); - }), - ); - it.effect("skips icon metadata paths outside the workspace", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; @@ -510,20 +255,5 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { expect(resolved).toBeNull(); }), ); - - it.effect("continues to later sources after an outside-root icon href", () => - Effect.gen(function* () { - const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "index.html", ''); - yield* writeTextFile(cwd, "public/index.html", ''); - yield* writeTextFile(cwd, "public/brand/logo.svg", "brand"); - - const resolved = yield* resolver.resolvePath(cwd); - - expect(resolved).not.toBeNull(); - expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); - }), - ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index aef40716ae81..e42cb5fbe919 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -111,6 +111,8 @@ export class ProjectFaviconResolver extends Context.Service< cwd: string, faviconPath?: string, ) => Effect.Effect; + /** Forgets cached lookups for a workspace whose files changed, such as a finished clone. */ + readonly invalidate: (cwd: string) => Effect.Effect; } >()("t3/project/ProjectFaviconResolver") {} @@ -340,7 +342,18 @@ export const make = Effect.gen(function* () { return yield* Cache.get(faviconCache, key); }); - return ProjectFaviconResolver.of({ resolvePath }); + const invalidate: ProjectFaviconResolver["Service"]["invalidate"] = (cwd) => + Cache.keys(faviconCache).pipe( + Effect.flatMap((keys) => + Effect.forEach( + Array.from(keys).filter((key) => parseFaviconCacheKey(key).cwd === cwd), + (key) => Cache.invalidate(faviconCache, key), + { discard: true }, + ), + ), + ); + + return ProjectFaviconResolver.of({ resolvePath, invalidate }); }); export const layer = Layer.effect(ProjectFaviconResolver, make); diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts deleted file mode 100644 index c2f9ee1a89b1..000000000000 --- a/apps/server/src/project/ProjectMutation.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { CommandId, ProjectId, type Project } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Ref from "effect/Ref"; - -import { projectMutationOperation } from "./ProjectMutation.ts"; -import { type ProjectService } from "./ProjectService.ts"; - -const projectId = ProjectId.make("project:mutation-mapping"); -const project = { - id: projectId, - title: "Mapping", - workspaceRoot: "/work/mapping", - repositoryIdentity: null, - faviconPath: null, - projectIcon: null, - defaultModelSelection: null, - defaultThreadEnvMode: null, - autoPull: false, - scripts: [], - createdAt: "2026-09-05T00:00:00.000Z", - updatedAt: "2026-09-05T00:00:00.000Z", - deletedAt: null, -} satisfies Project; - -it.effect("preserves every project mutation field", () => - Effect.gen(function* () { - const calls = yield* Ref.make>([]); - const projects: Pick = { - create: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - update: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - delete: (input) => - Ref.update(calls, (entries) => [...entries, input]).pipe(Effect.as(project)), - }; - - yield* projectMutationOperation(projects, { - type: "project.create", - commandId: CommandId.make("command:create"), - projectId, - title: "Created", - workspaceRoot: "/work/created", - createWorkspaceRootIfMissing: true, - defaultModelSelection: null, - scripts: [], - }); - yield* projectMutationOperation(projects, { - type: "project.update", - commandId: CommandId.make("command:update"), - projectId, - title: "Updated", - workspaceRoot: "/work/updated", - defaultModelSelection: null, - autoPull: false, - projectIcon: null, - faviconPath: null, - defaultThreadEnvMode: null, - scripts: [], - }); - yield* projectMutationOperation(projects, { - type: "project.delete", - commandId: CommandId.make("command:delete"), - projectId, - force: true, - }); - - assert.deepEqual(yield* Ref.get(calls), [ - { - commandId: "command:create", - projectId, - title: "Created", - workspaceRoot: "/work/created", - createWorkspaceRootIfMissing: true, - defaultModelSelection: null, - scripts: [], - }, - { - commandId: "command:update", - projectId, - title: "Updated", - workspaceRoot: "/work/updated", - defaultModelSelection: null, - autoPull: false, - projectIcon: null, - faviconPath: null, - defaultThreadEnvMode: null, - scripts: [], - }, - { commandId: "command:delete", projectId, force: true }, - ]); - }), -); diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 0e9f01dc653c..41804842d520 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -58,6 +58,7 @@ const layerServices = Layer.mergeAll( resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: () => Effect.succeed(null), }), ), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 67e76bfeafbc..9347acef12c8 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -46,6 +46,7 @@ const layerMetadata = Layer.merge( }), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), }), ); @@ -202,28 +203,6 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); - it.effect("rejects active workspace collisions", () => - Effect.gen(function* () { - const service = yield* ProjectService.ProjectService; - yield* TestClock.setTime(Date.parse("2026-06-20T10:00:00.000Z")); - yield* service.create({ - commandId: CommandId.make("command:collision:first"), - projectId: ProjectId.make("project:collision:first"), - title: "First", - workspaceRoot: "/work/shared", - }); - const error = yield* service - .create({ - commandId: CommandId.make("command:collision:second"), - projectId: ProjectId.make("project:collision:second"), - title: "Second", - workspaceRoot: "/work/shared", - }) - .pipe(Effect.flip); - assert.equal(error._tag, "ProjectConflictError"); - }), - ); - it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; @@ -485,111 +464,6 @@ it.layer(layerTest)("ProjectService", (it) => { ); }); -it.effect( - "returns project mutations before slow enrichment and shares the eventual result across reads", - () => - Effect.gen(function* () { - const repositoryStarted = yield* Deferred.make(); - const releaseRepository = yield* Deferred.make(); - const repositoryCalls = yield* Ref.make(0); - const faviconCalls = yield* Ref.make(0); - - const layerSlowMetadata = Layer.merge( - Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { - resolve: (workspaceRoot) => - Effect.gen(function* () { - yield* Ref.update(repositoryCalls, (count) => count + 1); - yield* Deferred.succeed(repositoryStarted, undefined); - yield* Deferred.await(releaseRepository); - return { - canonicalKey: "github.com/t3tools/slow-project", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/slow-project.git", - }, - rootPath: workspaceRoot, - }; - }), - }), - Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { - resolvePath: (workspaceRoot) => - Ref.updateAndGet(faviconCalls, (count) => count + 1).pipe( - Effect.as(`${workspaceRoot}/favicon.svg`), - ), - }), - ); - - yield* Effect.gen(function* () { - const service = yield* ProjectService.ProjectService; - const projectId = ProjectId.make("project:slow-enrichment"); - const createFiber = yield* service - .create({ - commandId: CommandId.make("command:slow-enrichment:create"), - projectId, - title: "Slow enrichment", - workspaceRoot: "/work/slow-enrichment", - }) - .pipe(Effect.forkChild({ startImmediately: true })); - - yield* Deferred.await(repositoryStarted); - yield* Effect.yieldNow; - assert.isDefined(createFiber.pollUnsafe()); - - const created = yield* Fiber.join(createFiber); - assert.isNull(created.repositoryIdentity); - assert.isNull(created.faviconPath); - - const updated = yield* service.update({ - commandId: CommandId.make("command:slow-enrichment:update"), - projectId, - title: "Updated before enrichment", - }); - assert.equal(updated.title, "Updated before enrichment"); - assert.isNull(updated.repositoryIdentity); - assert.equal(updated.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const immediateReadFiber = yield* service - .getById(projectId) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.yieldNow; - assert.isDefined(immediateReadFiber.pollUnsafe()); - const immediateRead = Option.getOrThrow(yield* Fiber.join(immediateReadFiber)); - assert.isNull(immediateRead.repositoryIdentity); - assert.equal(immediateRead.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const snapshotFiber = yield* service.snapshot.pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.yieldNow; - assert.isDefined(snapshotFiber.pollUnsafe()); - const immediateSnapshot = yield* Fiber.join(snapshotFiber); - assert.isNull(immediateSnapshot.projects[0]?.repositoryIdentity ?? null); - - yield* Deferred.succeed(releaseRepository, undefined); - - const firstProject = yield* waitForProject( - service, - projectId, - (project) => project.repositoryIdentity !== null && project.faviconPath !== null, - ); - assert.isDefined(firstProject); - assert.equal( - firstProject.repositoryIdentity?.canonicalKey, - "github.com/t3tools/slow-project", - ); - assert.equal(firstProject.faviconPath, "/work/slow-enrichment/favicon.svg"); - - const byId = Option.getOrThrow(yield* service.getById(projectId)); - const secondSnapshot = yield* service.snapshot; - assert.equal(byId.repositoryIdentity?.canonicalKey, "github.com/t3tools/slow-project"); - assert.equal(secondSnapshot.projects[0]?.faviconPath, "/work/slow-enrichment/favicon.svg"); - assert.equal(yield* Ref.get(repositoryCalls), 1); - assert.equal(yield* Ref.get(faviconCalls), 1); - }).pipe(Effect.provide(layerTestFor(layerSlowMetadata))); - }), -); - it.effect("keeps project snapshots available when optional metadata enrichment fails", () => Effect.gen(function* () { const layerFailingMetadata = Layer.merge( @@ -597,6 +471,7 @@ it.effect("keeps project snapshots available when optional metadata enrichment f resolve: () => Effect.succeed(null), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Effect.fail( new ProjectFaviconResolver.ProjectFaviconResolutionError({ @@ -647,6 +522,7 @@ it.effect("invalidates workspace-derived metadata when a project moves", () => ), }), Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + invalidate: () => Effect.void, resolvePath: (workspaceRoot) => Ref.get(metadataVersion).pipe( Effect.map((version) => `${workspaceRoot}/favicon-v${version}.svg`), diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 74d92f42cf7f..2fce68ed27b9 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,16 +1,10 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { SourceControlProviderError } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; @@ -27,192 +21,7 @@ const git = (cwd: string, args: ReadonlyArray) => }); }).pipe(Effect.provide(ProcessRunner.layer)); -const layerRepositoryIdentityResolverTest = (options: { - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; -}) => - Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make({ - cacheCapacity: 16, - ...options, - }), - ).pipe(Layer.provide(ProcessRunner.layer)); - it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { - it.effect("refreshes the Git root only when requested", () => { - const calls: Array> = []; - let rootPath = "/repo"; - let remoteUrl = "git@github.com:T3Tools/t3code.git"; - let refinements = 0; - let refinementFails = false; - const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.sync(() => { - calls.push(input.args); - return { - stdout: input.args.includes("rev-parse") - ? `${rootPath}\n` - : `origin\t${remoteUrl} (fetch)\n`, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - const layerResolver = Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make({ - refine: (identity) => { - refinements++; - if (refinementFails) - return Effect.fail( - new SourceControlProviderError({ - provider: SourceControlProviderKind.make("forgejo"), - operation: "detectProvider", - cwd: rootPath, - detail: "account unavailable", - }), - ); - return Effect.succeed( - identity.canonicalKey.startsWith("ssh.forge.test/") - ? { - ...identity, - provider: "forgejo", - webUrl: "http://forge.test:3000/git/team/repo", - } - : identity, - ); - }, - }), - ).pipe(Layer.provide(layerProcessRunner)); - - return Effect.gen(function* () { - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const first = yield* resolver.resolve("/repo/packages/web"); - rootPath = "/repo/packages/web"; - // Longer than the one-minute cadence of the background sweeps. - yield* TestClock.adjust(Duration.minutes(10)); - const second = yield* resolver.resolve("/repo/packages/web"); - - expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(second).toEqual(first); - expect(refinements).toBe(1); - expect(calls).toEqual([ - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo", "remote", "-v"], - ]); - - const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); - expect(refreshed?.rootPath).toBe("/repo/packages/web"); - expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); - expect(calls.slice(2)).toEqual([ - ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], - ["-C", "/repo/packages/web", "remote", "-v"], - ]); - remoteUrl = "git@ssh.forge.test:team/repo.git"; - const forgejo = yield* resolver.resolve(rootPath, { refresh: true }); - expect(forgejo?.webUrl).toBe("http://forge.test:3000/git/team/repo"); - expect(forgejo?.provider).toBe("forgejo"); - expect(forgejo?.canonicalKey).toBe("ssh.forge.test/team/repo"); - expect(forgejo?.locator.remoteUrl).toBe(remoteUrl); - expect(yield* resolver.resolve(rootPath)).toEqual(forgejo); - expect(refinements).toBe(3); - refinementFails = true; - const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); - expect(unavailable?.webUrl).toBeUndefined(); - expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver))); - }); - - it.effect("retries Git root discovery after the negative TTL", () => { - let attempts = 0; - let unavailable = true; - const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.sync(() => { - attempts++; - return { - stdout: unavailable - ? "" - : input.args.includes("remote") - ? "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n" - : "/repo\n", - stderr: unavailable ? "temporary Git failure" : "", - code: ChildProcessSpawner.ExitCode(unavailable ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - const layerResolver = Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make(), - ).pipe(Layer.provide(layerProcessRunner)); - - return Effect.gen(function* () { - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); - const failedAttempts = attempts; - unavailable = false; - expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); - expect(attempts).toBe(failedAttempts); - - yield* TestClock.adjust(Duration.minutes(1)); - const recovered = yield* resolver.resolve("/repo/packages/web"); - expect(recovered?.rootPath).toBe("/repo"); - expect(recovered?.canonicalKey).toBe("github.com/t3tools/t3code"); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver))); - }); - - it.effect.each(["times out", "cannot start"] as const)( - "skips the bare repository probe when Git root discovery %s", - (failure) => { - let attempts = 0; - const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { - run: (input) => - Effect.suspend(() => { - attempts++; - return failure === "times out" - ? Effect.succeed({ - stdout: "", - stderr: "", - code: null, - timedOut: true, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }) - : Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: new Error("git is not installed"), - }), - ); - }), - }); - const layerResolver = Layer.effect( - RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make(), - ).pipe(Layer.provide(layerProcessRunner)); - - return Effect.gen(function* () { - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - expect(yield* resolver.resolve("/repo")).toBeNull(); - expect(attempts).toBe(1); - }).pipe(Effect.provide(layerResolver)); - }, - ); - it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -477,86 +286,4 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.name).toBe("t3code"); }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - - it.effect( - "keeps null identities cached across repeated resolves until the negative TTL expires", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-late-remote-test-", - }); - - yield* git(cwd, ["init"]); - - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).toBeNull(); - - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - for (const _attempt of [1, 2, 3]) { - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); - } - - yield* TestClock.adjust(Duration.millis(120)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(refreshedIdentity?.name).toBe("t3code"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - layerRepositoryIdentityResolverTest({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.seconds(1), - }), - ), - ), - ), - ); - - it.effect("refreshes cached identities after the positive TTL when a remote changes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-remote-change-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const initialIdentity = yield* resolver.resolve(cwd); - expect(initialIdentity).not.toBeNull(); - expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); - - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).not.toBeNull(); - expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); - - yield* TestClock.adjust(Duration.millis(180)); - - const refreshedIdentity = yield* resolver.resolve(cwd); - expect(refreshedIdentity).not.toBeNull(); - expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); - expect(refreshedIdentity?.displayName).toBe("T3Tools/t3code-next"); - expect(refreshedIdentity?.name).toBe("t3code-next"); - }).pipe( - Effect.provide( - Layer.merge( - TestClock.layer(), - layerRepositoryIdentityResolverTest({ - negativeCacheTtl: Duration.millis(50), - positiveCacheTtl: Duration.millis(100), - }), - ), - ), - ), - ); }); diff --git a/apps/server/src/project/T3ProjectFileLoader.test.ts b/apps/server/src/project/T3ProjectFileLoader.test.ts index 5b42ea4cd213..5abc78098869 100644 --- a/apps/server/src/project/T3ProjectFileLoader.test.ts +++ b/apps/server/src/project/T3ProjectFileLoader.test.ts @@ -28,40 +28,6 @@ const writeProjectFile = Effect.fn("writeProjectFile")(function* (cwd: string, c it.layer(layerTest)("T3ProjectFileLoader", (it) => { describe("load", () => { - it.effect("loads and decodes a valid t3.json", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - yield* writeProjectFile( - cwd, - `{ - // JSONC is tolerated - "iconPath": "assets/logo.svg", - "scripts": [{ "name": "Dev", "command": "pnpm dev" }], - }`, - ); - - const loaded = yield* loader.load(cwd); - - expect(Option.isSome(loaded)).toBe(true); - if (Option.isSome(loaded)) { - expect(loaded.value.iconPath).toBe("assets/logo.svg"); - expect(loaded.value.scripts).toEqual([{ name: "Dev", command: "pnpm dev" }]); - } - }), - ); - - it.effect("returns none when t3.json is missing", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - - const loaded = yield* loader.load(cwd); - - expect(Option.isNone(loaded)).toBe(true); - }), - ); - it.effect("returns none for malformed JSON without failing", () => Effect.gen(function* () { const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; @@ -73,17 +39,5 @@ it.layer(layerTest)("T3ProjectFileLoader", (it) => { expect(Option.isNone(loaded)).toBe(true); }), ); - - it.effect("returns none for schema-invalid files without failing", () => - Effect.gen(function* () { - const loader = yield* T3ProjectFileLoader.T3ProjectFileLoader; - const cwd = yield* makeTempDir; - yield* writeProjectFile(cwd, '{ "scripts": [{ "name": "Dev" }] }'); - - const loaded = yield* loader.load(cwd); - - expect(Option.isNone(loaded)).toBe(true); - }), - ); }); }); diff --git a/apps/server/src/project/WorktreeSetupTracker.test.ts b/apps/server/src/project/WorktreeSetupTracker.test.ts index e96a1815f185..ed3fcc027c72 100644 --- a/apps/server/src/project/WorktreeSetupTracker.test.ts +++ b/apps/server/src/project/WorktreeSetupTracker.test.ts @@ -1,13 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { ThreadId, WorktreeSetupSnapshot } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts"; @@ -76,34 +73,6 @@ describe("WorktreeSetupTracker", () => { }), ); - it.effect("stream never steps back behind the snapshot it started from", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["agent"], - fiber: null, - }); - yield* tracker.stageStatus(threadId, "agent", "running"); - yield* tracker.stageStatus(threadId, "agent", "done"); - - const collected = yield* tracker.stream(threadId).pipe( - Stream.takeUntil((snapshot) => snapshot?.phase === "done"), - Stream.runCollect, - Effect.forkChild, - ); - yield* Effect.yieldNow; - yield* tracker.finish(threadId, "done"); - - const snapshots = yield* Fiber.join(collected); - expect(snapshots.length).toBeGreaterThan(0); - expect(snapshots.every((snapshot) => (snapshot?.sequence ?? -1) >= 2)).toBe(true); - expect(snapshots.at(-1)?.phase).toBe("done"); - }), - ); - it.effect("a new setup on the same thread keeps sequences increasing", () => Effect.gen(function* () { const tracker = yield* WorktreeSetupTracker.make; @@ -138,24 +107,6 @@ describe("WorktreeSetupTracker", () => { }), ); - it.effect("finished setups are dropped after the retention window", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["agent"], - fiber: null, - }); - yield* tracker.finish(threadId, "done"); - expect((yield* tracker.get(threadId))?.phase).toBe("done"); - - yield* TestClock.adjust(Duration.seconds(31)); - expect(yield* tracker.get(threadId)).toBeNull(); - }), - ); - it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () => Effect.gen(function* () { const tracker = yield* WorktreeSetupTracker.make; @@ -189,30 +140,4 @@ describe("WorktreeSetupTracker", () => { yield* Fiber.interrupt(fiber); }), ); - - it.effect("clamps free text to the contract limits before publishing", () => - Effect.gen(function* () { - const tracker = yield* WorktreeSetupTracker.make; - yield* tracker.begin({ - threadId, - branch: null, - baseRef: null, - stages: ["checkout", "setup-script"], - fiber: null, - }); - const long = "x".repeat(2_000); - - yield* tracker.stageStatus(threadId, "checkout", "done", long); - yield* tracker.stage(threadId, "setup-script", { detail: long }); - yield* tracker.appendTail(threadId, "setup-script", long); - yield* tracker.finish(threadId, "failed", long); - - const snapshot = yield* tracker.get(threadId); - expect(snapshot?.stages[0]?.detail?.length).toBe(200); - expect(snapshot?.stages[1]?.detail?.length).toBe(200); - expect(snapshot?.stages[1]?.tail[0]?.length).toBe(400); - expect(snapshot?.error?.length).toBe(1000); - expect(Schema.is(WorktreeSetupSnapshot)(snapshot)).toBe(true); - }), - ); }); diff --git a/apps/server/src/project/http.test.ts b/apps/server/src/project/http.test.ts deleted file mode 100644 index ba20c5fbbc9f..000000000000 --- a/apps/server/src/project/http.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -import { - ProjectConflictError, - ProjectNotEmptyError, - ProjectNotFoundError, - ProjectOperationError, -} from "./ProjectService.ts"; -import { ServerRuntimeStartupError } from "../serverRuntimeStartup.ts"; -import { failProjectMutation } from "./http.ts"; - -const projectId = ProjectId.make("project:http-mutation"); - -it.effect.each([ - new ProjectNotFoundError({ projectId }), - new ProjectNotEmptyError({ projectId }), - new ProjectConflictError({ - projectId, - workspaceRoot: "/workspace/project", - conflictingProjectId: ProjectId.make("project:http-mutation-conflict"), - }), -])("maps expected project mutation failures to invalid requests", (cause) => - Effect.gen(function* () { - const error = yield* failProjectMutation(cause).pipe(Effect.flip); - - assert.equal(error._tag, "EnvironmentRequestInvalidError"); - assert.equal(error.code, "invalid_request"); - assert.equal(error.reason, "invalid_command"); - }), -); - -it.effect.each([ - new ProjectOperationError({ - operation: "dispatch-project-command", - projectId, - cause: "database unavailable", - }), - new ServerRuntimeStartupError({ - mode: "web", - host: null, - port: 0, - cause: "startup unavailable", - }), -])("keeps operational and startup failures internal", (cause) => - Effect.gen(function* () { - const error = yield* failProjectMutation(cause).pipe(Effect.flip); - - assert.equal(error._tag, "EnvironmentInternalError"); - assert.equal(error.code, "internal_error"); - assert.equal(error.reason, "project_mutation_failed"); - }), -); diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index e1a04655d835..825e6c1cc8f4 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -17,7 +17,7 @@ import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; import * as ProjectService from "./ProjectService.ts"; import { projectMutationOperation } from "./ProjectMutation.ts"; -export const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( +const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( cause: ProjectService.ProjectServiceError | ServerRuntimeStartup.ServerRuntimeStartupError, ) { if ( diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index 6c8cb5aebf83..bc7c14d3b3c8 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -6,11 +6,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -21,7 +18,6 @@ import { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, - probeAcpRegistryConfiguration, } from "@t3tools/provider-acp-registry/testing"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; @@ -141,61 +137,6 @@ describe("ACP Registry probe", () => { expect(methods[0]).not.toHaveProperty("command"); }); - it("omits overlong opaque auth method and environment variable ids", () => { - const methods = normalizeAcpRegistryAuthMethods([ - { id: "x".repeat(129), name: "Invalid" }, - { - id: "api-key", - name: "API key", - type: "env_var", - vars: [ - { name: "y".repeat(129), label: "Invalid" }, - { name: "OPENAI_API_KEY", label: "OpenAI API key" }, - ], - }, - ]); - - expect(methods).toEqual([ - { - id: "api-key", - name: "API key", - description: null, - type: "env_var", - envVarNames: ["OPENAI_API_KEY"], - }, - ]); - }); - - it("falls back to model-category configuration options", () => { - const result = acpRegistryProbeResult(instanceId, { - sessionId: "probe-session", - initializeResult: { protocolVersion: 1 }, - sessionSetupResult: { - sessionId: "probe-session", - configOptions: [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "sonnet", - options: [ - { value: "sonnet", name: "Sonnet", description: "Balanced" }, - { value: "haiku", name: "Haiku" }, - ], - }, - ], - }, - modelConfigId: "model", - } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult); - - expect(result.models).toEqual([ - { id: "sonnet", name: "Sonnet", description: "Balanced" }, - { id: "haiku", name: "Haiku", description: null }, - ]); - expect(result.currentModelId).toBe("sonnet"); - }); - it("omits overlong opaque model ids instead of publishing mutated ids", () => { const result = acpRegistryProbeResult(instanceId, { sessionId: "probe-session", @@ -377,97 +318,6 @@ describe("ACP Registry probe", () => { }); }); - it.effect("captures commands advertised just after session creation", () => - Effect.gen(function* () { - const result = yield* probeAcpRegistryConfiguration({ - instanceId, - settings: decodeSettings({ agentId: "mock-agent" }), - cwd: process.cwd(), - environment: process.env, - }); - - expect(result.slashCommands).toEqual([ - { - name: "review", - description: "Review the current changes", - input: { hint: "focus" }, - }, - ]); - expect(result.skills).toEqual([ - { - name: "workspace-skill", - description: "Run the workspace skill", - path: "acp://skill/workspace-skill", - scope: "agent", - enabled: true, - }, - ]); - }).pipe( - Effect.provideService( - AcpRegistrySupport.AcpRegistryCatalog, - AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Effect.succeed({ - agent: { - id: "mock-agent", - name: "Mock Agent", - version: "1.0.0", - description: "ACP probe test agent", - distribution: { npx: { package: "mock-agent@1.0.0" } }, - }, - distribution: "npx", - spawn: { - command: "node", - args: [mockAgentPath], - env: { - ...process.env, - T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25", - }, - }, - }), - }), - ), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), - ); - - it.effect("includes package resolution in the probe timeout", () => - Effect.gen(function* () { - const resolveStarted = yield* Deferred.make(); - const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)), - }); - const probe = yield* probeAcpRegistryConfiguration({ - instanceId, - settings: decodeSettings({ agentId: "slow-agent" }), - cwd: process.cwd(), - environment: process.env, - }).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.flip, - Effect.forkChild({ startImmediately: true }), - ); - - yield* Deferred.await(resolveStarted); - yield* TestClock.adjust("60 seconds"); - - expect(yield* Fiber.join(probe)).toMatchObject({ - reason: "probe_failed", - message: expect.stringContaining("within 60 seconds"), - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => { const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ search: () => Effect.die("unused search"), @@ -524,50 +374,6 @@ describe("ACP Registry probe", () => { ); }); - it.effect("preserves the ACP method error for unsupported session management", () => { - const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ - search: () => Effect.die("unused search"), - prepare: () => Effect.die("unused prepare"), - inspect: () => Effect.die("unused inspect"), - uninstallManagedBinary: () => Effect.die("unused uninstall"), - resolve: () => - Effect.succeed({ - agent: { - id: "mock-agent", - name: "Mock Agent", - version: "1.0.0", - description: "ACP unsupported management test agent", - distribution: { npx: { package: "mock-agent@1.0.0" } }, - }, - distribution: "npx" as const, - spawn: { - command: "node", - args: [mockAgentPath], - env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" }, - }, - }), - }); - - return Effect.gen(function* () { - const failure = yield* listAcpRegistrySessions({ - instanceId, - settings: decodeSettings({ agentId: "mock-agent" }), - cwd: process.cwd(), - environment: process.env, - }).pipe(Effect.flip); - - expect(failure).toMatchObject({ - reason: "session_list_unsupported", - message: "The ACP agent does not advertise session listing.", - cause: { _tag: "AcpRequestError", code: -32601 }, - }); - }).pipe( - Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog), - Effect.provide(NodeServices.layer), - Effect.scoped, - ); - }); - it("distinguishes authentication failures from generic probe failures", () => { const advertised = normalizeAcpRegistryAuthMethods([ { id: "grok-login", name: "Log in with Grok" }, diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index 02e8b691a133..dfcfb2fecdc8 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -8,7 +8,6 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; @@ -143,21 +142,6 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( }); it.layer(NodeServices.layer)("AntigravityAuth", (it) => { - it.effect("accepts the same authorization URL from stderr and stdout", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ - authorizationUrls: [authorizationUrl, authorizationUrl], - }); - yield* harness.auth.controller.start(owner); - const waiting = yield* phase(harness.auth, "waiting"); - assert.equal(waiting.authorizationUrl, authorizationUrl); - - yield* Deferred.succeed(harness.authenticated, undefined); - yield* Deferred.succeed(harness.discovered, undefined); - yield* phase(harness.auth, "succeeded"); - }), - ); - it.effect("accepts a delayed duplicate after callback completion starts", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -352,24 +336,6 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); - it.effect("expires the flow at the official deadline and removes its URL", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const state = yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* TestClock.adjust("300 seconds"); - const failed = yield* phase(harness.auth, "failed"); - assert.include(failed.message ?? "", "expired"); - assert.isNull(failed.authorizationUrl); - yield* Deferred.await(harness.closed); - const late = yield* harness.auth.controller - .complete(owner, { flowId: state.flowId!, callbackUrl }) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(late)); - assert.equal(harness.forwarded(), 0); - }), - ); - it.effect("survives subscriber disconnect and does not replace a competing client's flow", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -422,45 +388,6 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); - it.effect("signs out after a slow packaged runtime starts", () => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const initialized = yield* Deferred.make(); - const harness = yield* makeHarness({ - beforeInitialize: Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(initialized)), - ), - }); - const logout = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("47 seconds"); - yield* Deferred.succeed(initialized, undefined); - assert.equal((yield* Fiber.join(logout)).phase, "idle"); - assert.include(harness.events, "logout"); - assert.deepEqual(harness.catalog(), []); - yield* Deferred.await(harness.closed); - }), - ); - - it.effect("closes a stalled sign-out process without clearing its account catalog", () => - Effect.gen(function* () { - const entered = yield* Deferred.make(); - const harness = yield* makeHarness({ - beforeInitialize: Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), - }); - const logout = yield* harness.auth.controller - .logout(Effect.void) - .pipe(Effect.exit, Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("90 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(logout))); - yield* Deferred.await(harness.closed); - assert.notInclude(harness.events, "logout"); - assert.deepEqual(harness.catalog(), ["previous-account-model"]); - yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped); - }), - ); - it.effect( "sign-out interrupts startup without interrupting its caller after startup returns", () => diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index efab762da7cb..e7bcbe7553f6 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -11,24 +11,14 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; -import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; -import { - resolveAntigravityReleaseAsset, - type AntigravityReleaseAsset, -} from "./antigravityRelease.ts"; - -import antigravityInitialize from "../../../../packages/effect-acp/test/fixtures/antigravity-initialize.json" with { type: "json" }; +import { type AntigravityReleaseAsset } from "./antigravityRelease.ts"; const serverContents = "antigravity runtime\n"; const harnessContents = "local harness\n"; @@ -308,299 +298,6 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - it.effect.each( - [ - { - name: "the expected release with protocol 1", - protocolVersion: 1, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "the expected release with protocol 2 and legacy fields", - protocolVersion: 2, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "an unsupported protocol version", - protocolVersion: 3, - agentName: "antigravity-acp", - version: "fixture-new", - valid: false, - }, - { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false }, - { - name: "a different version", - agentName: "antigravity-acp", - version: "other-version", - valid: false, - }, - { - name: "the Windows ARM release with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "fixture-new", - valid: true, - }, - { - name: "an invalid Windows ARM release with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "other-version", - valid: false, - }, - { - name: "a cancelled Windows ARM verification with locked child processes", - platform: "win32" as const, - arch: "arm64" as const, - agentName: "antigravity-acp", - version: "fixture-new", - cancel: true, - valid: false, - }, - ].flatMap((testCase) => { - const cleanupErrorTags = - testCase.platform === "win32" - ? (["Unknown", "PermissionDenied", "Busy"] as const) - : (["Unknown"] as const); - return cleanupErrorTags.map((cleanupErrorTag) => ({ - ...testCase, - cleanupErrorTag, - name: `${cleanupErrorTag}: ${testCase.name}`, - })); - }), - )("validates $name with initialize only and removes the disposable profile", (testCase) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const platform = testCase.platform ?? hostPlatform; - const encoder = new TextEncoder(); - const decodeRequest = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ - id: Schema.Union([Schema.String, Schema.Number]), - method: Schema.String, - }), - ), - ); - const methods: string[] = []; - const profiles = new Set(); - const cleanupRetryStarted = yield* Deferred.make(); - const initializationStarted = yield* Deferred.make(); - let childProcessesRunning = false; - let profileRemovalAttempts = 0; - let closedRuntimes = 0; - const spawner = ChildProcessSpawner.make( - Effect.fn("test.spawnAntigravityValidator")(function* (command) { - if (command._tag !== "StandardCommand") { - return yield* Effect.die("Expected one validation process."); - } - const profile = command.options.env?.GEMINI_HOME; - if (!profile) return yield* Effect.die("Expected a disposable validation profile."); - profiles.add(profile); - const helper = command.args[0] === "-e"; - if (!helper) childProcessesRunning = true; - if (!helper) { - if (platform === "win32") { - expect(command.options.env?.TEMP).toBe(profile); - expect(command.options.env?.TMP).toBe(profile); - } else { - expect(command.options.env?.TMPDIR).toBe(profile); - } - } - const output = yield* Queue.unbounded(); - const exited = yield* Deferred.make(); - const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( - Effect.asVoid, - ); - yield* Effect.addFinalizer(() => - terminate.pipe( - Effect.andThen(Queue.shutdown(output)), - Effect.andThen( - Effect.sync(() => { - if (!helper) closedRuntimes += 1; - }), - ), - ), - ); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2), - exitCode: helper - ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) - : Deferred.await(exited), - isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)), - kill: () => - Effect.sync(() => { - if (!helper) childProcessesRunning = false; - }).pipe(Effect.andThen(terminate)), - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((bytes: Uint8Array) => - Effect.gen(function* () { - const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe( - Effect.orDie, - ); - methods.push(request.method); - if (testCase.cancel) { - yield* Deferred.succeed(initializationStarted, undefined); - return; - } - yield* Queue.offer( - output, - encoder.encode( - `${encodeJsonString({ - jsonrpc: "2.0", - id: request.id, - ...(request.method === "initialize" - ? { - result: { - ...antigravityInitialize, - protocolVersion: testCase.protocolVersion ?? 2, - agentInfo: { name: testCase.agentName, version: testCase.version }, - }, - } - : { - error: { - code: -32601, - message: "Validation must not sign in or create sessions.", - }, - }), - })}\n`, - ), - ); - }), - ), - stdout: helper ? Stream.empty : Stream.fromQueue(output), - stderr: helper - ? Stream.make( - encoder.encode( - `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`, - ), - ) - : Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const { installation, stagingReleased } = yield* makeHarness({ - previous: true, - useDefaultValidation: true, - platform, - ...(testCase.arch ? { arch: testCase.arch } : {}), - archive: - platform === "win32" ? Buffer.from(zipFixtures.windows, "base64") : completeArchive, - fileSystem: FileSystem.FileSystem.of({ - ...fs, - remove: (target, options) => - Effect.suspend(() => { - if (platform === "win32" && profiles.has(target)) { - profileRemovalAttempts += 1; - expect(childProcessesRunning).toBe(false); - // Even after the tree exits, Windows can briefly retain a DLL handle. - if (profileRemovalAttempts === 1) { - return Deferred.succeed(cleanupRetryStarted, undefined).pipe( - Effect.andThen( - Effect.fail( - PlatformError.systemError({ - _tag: testCase.cleanupErrorTag, - module: "FileSystem", - method: "remove", - description: "Unpacked DLL is in use", - }), - ), - ), - ); - } - } - return fs.remove(target, options); - }), - }), - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - const started = yield* installation.start; - const cancellation = testCase.cancel - ? yield* Deferred.await(initializationStarted).pipe( - Effect.andThen(installation.cancel(started.operationId!)), - Effect.forkChild, - ) - : undefined; - if (platform === "win32") { - yield* Effect.raceFirst( - Deferred.await(cleanupRetryStarted).pipe(Effect.andThen(TestClock.adjust("100 millis"))), - terminalState(installation), - ); - } - if (cancellation) yield* Fiber.join(cancellation); - expect((yield* terminalState(installation)).phase).toBe( - testCase.cancel ? "cancelled" : testCase.valid ? "succeeded" : "failed", - ); - yield* Deferred.await(stagingReleased); - expect(methods).toEqual(["initialize"]); - expect(closedRuntimes).toBe(1); - expect(profiles.size).toBe(1); - if (platform === "win32") expect(profileRemovalAttempts).toBe(2); - for (const profile of profiles) { - expect(yield* fs.exists(profile)).toBe(false); - } - if (testCase.valid) { - expect((yield* installation.resolve()).version).toBe("fixture-new"); - } else { - yield* expectPreviousRelease(installation); - } - }), - ); - - it.effect.each([ - { - name: "IPv6 startup failure", - stderr: - "enforce_kernel_ipv6_support.cc:70] Check failed: AddressFamilySupported(AF_INET6, &loopback6_ok)", - message: - "Antigravity requires IPv6 socket support. Enable IPv6 in this environment. On WSL, remove ipv6.disable=1 from .wslconfig and run wsl --shutdown.", - }, - { - name: "other startup failure", - stderr: "Private startup diagnostics: internal runtime failure", - message: "The downloaded Antigravity runtime could not start in this environment.", - }, - ])( - "reports a fixed diagnostic for $name and preserves the previous release after SIGABRT", - (testCase) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const { installation, stagingReleased } = yield* makeHarness({ - previous: true, - useDefaultValidation: true, - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - spawner.spawn( - command._tag === "StandardCommand" && command.command.endsWith(executableName) - ? ChildProcess.make(process.execPath, [ - "-e", - `process.stderr.write(${encodeJsonString(testCase.stderr)}, () => process.kill(process.pid, 'SIGABRT'));`, - ]) - : command, - ), - ), - ), - ); - yield* installation.start; - expect(yield* terminalState(installation)).toMatchObject({ - phase: "failed", - message: testCase.message, - }); - yield* Deferred.await(stagingReleased); - yield* expectPreviousRelease(installation); - }), - { skip: hostPlatform === "win32" }, - ); - it.effect("accepts an encoded Content-Length when the body is compressed", () => Effect.gen(function* () { // dl.google.com gzips the archive and reports the encoded size. The decoded @@ -1099,28 +796,4 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { expect(requests).toEqual([]); }), ); - - it("resolves all supported platform release assets including Intel Mac", () => { - const supportedPlatforms: Array<{ readonly platform: NodeJS.Platform; readonly arch: string }> = - [ - { platform: "darwin", arch: "arm64" }, - { platform: "darwin", arch: "x64" }, - { platform: "linux", arch: "x64" }, - { platform: "linux", arch: "arm64" }, - { platform: "win32", arch: "x64" }, - { platform: "win32", arch: "arm64" }, - ]; - - for (const { platform, arch } of supportedPlatforms) { - const asset = resolveAntigravityReleaseAsset(platform, arch); - expect(asset).not.toBeNull(); - expect(asset?.version).toBe("1.3.0"); - expect(asset?.url).toContain("1.3.0"); - expect(asset?.archiveBytes).toBeGreaterThan(0); - expect(asset?.executable.bytes).toBeGreaterThan(0); - expect(asset?.harness.bytes).toBeGreaterThan(0); - } - - expect(resolveAntigravityReleaseAsset("freebsd", "x64")).toBeNull(); - }); }); diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index be709add4f60..ade033551f65 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -15,7 +15,6 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -179,23 +178,6 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( }); describe("Antigravity model catalog", () => { - it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => { - const models = buildAntigravityModelsFromSession(sessionSetupResult); - expect(models.map((model) => [model.slug, model.name])).toEqual( - modelOptions.map((option) => [option.value, option.name]), - ); - expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([ - "gemini-3.7-flash-high", - ]); - expect( - models - .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) - .map((model) => model.slug), - ).toEqual(["gemini-3.7-flash-high"]); - expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true); - expect(models.every((model) => !model.isCustom)).toBe(true); - }); - it("uses legacy session models only when model config is absent", () => { const fromLegacy = buildAntigravityModelsFromSession({ models: sessionSetupResult.models, @@ -257,45 +239,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("ignores account updates while disabled", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness({ enabled: false }); - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - yield* harness.provider.onSignedOut; - const snapshot = yield* harness.provider.snapshot.refresh; - expect(snapshot).toMatchObject({ - enabled: false, - installed: false, - status: "disabled", - auth: { status: "unknown" }, - models: [], - slashCommands: [], - workspaceSnapshots: [], - }); - expect(yield* Ref.get(harness.probeCalls)).toBe(0); - }), - ), - ); - - it.effect("publishes the configured sign-in method before any account is checked", () => - Effect.scoped( - Effect.gen(function* () { - const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { - stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), - probe: Effect.succeed(initializeResult), - supportsTextGeneration: Effect.succeed(true), - auth: { type: "gemini-api-key", label: "Gemini API key" }, - }); - expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ - status: "unknown", - type: "gemini-api-key", - }); - }), - ), - ); - it.effect("treats initialize as installation proof, not account or model discovery", () => Effect.scoped( Effect.gen(function* () { @@ -351,21 +294,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("does not retain a disposable sign-in workspace", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted(started); - yield* harness.provider.onAvailableCommands(commands); - const snapshot = yield* harness.provider.snapshot.getSnapshot; - expect(snapshot.models).toHaveLength(11); - expect(snapshot.slashCommands).toEqual(expectedCommands); - expect(snapshot.workspaceSnapshots).toEqual([]); - }), - ), - ); - it.effect("clears all account metadata on sign-out and authentication failure", () => Effect.scoped( Effect.gen(function* () { @@ -444,30 +372,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("accepts a known version from a later installation probe", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - yield* harness.provider.onSessionStarted({ - ...started, - initializeResult: { - ...initializeResult, - agentInfo: { ...initializeResult.agentInfo, version: "1.3.0" }, - }, - }); - yield* Ref.set( - harness.probe, - Effect.succeed({ - ...initializeResult, - agentInfo: { ...initializeResult.agentInfo, version: "1.4.0" }, - }), - ); - expect((yield* harness.provider.snapshot.refresh).version).toBe("1.4.0"); - }), - ), - ); - it.effect("does not replace a handshake version with an unknown custom-launcher probe", () => Effect.scoped( Effect.gen(function* () { @@ -539,58 +443,6 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("allows a slow packaged runtime health check to finish", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const initialized = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(initialized))), - ); - - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("47 seconds"); - yield* Deferred.succeed(initialized, initializeResult); - const snapshot = yield* Fiber.join(refresh); - - expect(snapshot).toMatchObject({ - installed: true, - status: "warning", - auth: { status: "unknown" }, - }); - }), - ), - ); - - it.effect("closes a stalled health probe at its deadline", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.initialize; - const entered = yield* Deferred.make(); - const closed = yield* Deferred.make(); - yield* Ref.set( - harness.probe, - Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Effect.never), - Effect.ensuring(Deferred.succeed(closed, undefined)), - ), - ); - const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild); - yield* Deferred.await(entered); - yield* TestClock.adjust("90 seconds"); - const snapshot = yield* Fiber.join(refresh); - yield* Deferred.await(closed); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("90 seconds"); - }), - ), - ); - it.effect("distinguishes missing executables from a failed installed executable", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 2df1ac024753..bf47ceb1a787 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,66 +1,19 @@ -// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. -import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; -import { vi } from "vite-plus/test"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; -import { - ClaudeSettings, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import * as NodeFSP from "node:fs/promises"; +import { ClaudeSettings } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Result from "effect/Result"; - -import { - buildClaudeCapabilitiesProbeQueryOptions, - CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - probeClaudeCapabilities, - probeClaudeWorkspaceSnapshot, -} from "./ClaudeProvider.ts"; -import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; +import { probeClaudeCapabilities } from "./ClaudeProvider.ts"; +// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import { vi } from "vite-plus/test"; +import { assert, it } from "@effect/vitest"; -vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -it("isolates Claude capability probes without dropping workspace setting sources", () => { - const abortController = new AbortController(); - const options = buildClaudeCapabilitiesProbeQueryOptions({ - executablePath: "/usr/bin/claude", - abortController, - environment: { - HOME: "/home/user", - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - FORCE_CODE_TERMINAL: "1", - }, - cwd: "/workspace/project", - managedMcpConfig: false, - }); - - assert.deepEqual(options.mcpServers, {}); - assert.equal(options.strictMcpConfig, true); - assert.equal(options.cwd, "/workspace/project"); - assert.deepEqual(options.settingSources, [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES]); - assert.deepEqual(options.settings, { disableAllHooks: true }); - assert.deepEqual(options.allowedTools, []); - assert.equal(options.persistSession, false); - assert.equal(options.pathToClaudeCodeExecutable, "/usr/bin/claude"); - assert.equal(options.abortController, abortController); - assert.equal(options.env?.HOME, "/home/user"); - assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); - assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); - assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); - assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); -}); +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ @@ -78,262 +31,6 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect( - "discovers commands and skills separately for each cwd without replacing machine metadata", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspaces-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspaces = [path.join(tempDir, "one"), path.join(tempDir, "two")]; - for (const cwd of workspaces) { - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - } - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated", email: "machine@example.com" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - let usageCalls = 0; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - assert.equal(options?.env?.CLAUDE_CONFIG_DIR, configDir); - assert.equal(options?.env?.T3_WORKSPACE_PROBE, "owned-instance"); - return { - initializationResult: async () => ({ - account: { email: "workspace@example.com" }, - commands: [ - { - name: options?.cwd === workspaces[0] ? "start-session" : "other-project", - description: "Project command", - argumentHint: "[topic]", - }, - { name: "nested:review", description: "Review changes", argumentHint: "" }, - { name: "NESTED:review", description: "", argumentHint: "[path]" }, - { name: "compact", description: "Provider compact", argumentHint: "" }, - { name: "user-command", description: "Existing user command", argumentHint: "" }, - ], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => { - usageCalls++; - return { rate_limits_available: false, rate_limits: null }; - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - for (const [index, cwd] of workspaces.entries()) { - const scoped = yield* probeClaudeWorkspaceSnapshot( - decodeClaudeSettings({ homePath: configDir }), - machineSnapshot, - cwd, - { ...process.env, T3_WORKSPACE_PROBE: "owned-instance" }, - ); - assert.deepEqual(scoped, { - ...machineSnapshot, - slashCommandsPending: false, - slashCommands: [ - COMPACT_SLASH_COMMAND, - { - name: index === 0 ? "start-session" : "other-project", - description: "Project command", - input: { hint: "[topic]" }, - }, - { name: "nested:review", description: "Review changes", input: { hint: "[path]" } }, - { name: "user-command", description: "Existing user command" }, - ], - skills: [ - { - name: "existing-skill", - path: path.join(cwd, ".claude", "skills", "existing-skill", "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - } - assert.deepEqual( - query.mock.calls.map(([input]) => input.options?.cwd), - workspaces, - ); - assert.equal(usageCalls, 0); - }).pipe(Effect.scoped), - ); - - it.effect("keeps readable skills during failed command discovery and recovers on retry", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-workspace-retry-" }); - const skillDir = path.join(cwd, ".claude", "skills", "existing-skill"); - yield* fs.makeDirectory(skillDir, { recursive: true }); - yield* fs.writeFileString( - path.join(skillDir, "SKILL.md"), - "---\nname: existing-skill\ndescription: Existing project skill\n---\nUse this skill.", - ); - const machineSnapshot = { - instanceId: ProviderInstanceId.make("claude"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: true, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "2.1.288", - models: [], - slashCommands: [{ name: "server-cwd-only" }], - skills: [], - } satisfies ServerProvider; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => - Promise.reject( - new Error("Initialization failed"), - ), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const settings = decodeClaudeSettings({ homePath: cwd }); - const failed = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(failed, { - ...machineSnapshot, - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - skills: [ - { - name: "existing-skill", - path: path.join(skillDir, "SKILL.md"), - enabled: true, - scope: "project", - description: "Existing project skill", - }, - ], - }); - query.mockImplementation( - () => - ({ - initializationResult: async () => ({ - commands: [{ name: "recovered", description: "", argumentHint: "" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: false, - rate_limits: null, - }), - }) as ReturnType, - ); - const recovered = yield* probeClaudeWorkspaceSnapshot(settings, machineSnapshot, cwd); - assert.deepEqual(recovered.slashCommands, [COMPACT_SLASH_COMMAND, { name: "recovered" }]); - assert.equal(recovered.status, "ready"); - assert.equal(recovered.slashCommandsPending, false); - assert.deepEqual(recovered.skills, failed.skills); - const disabled = yield* probeClaudeWorkspaceSnapshot( - { ...settings, enabled: false }, - machineSnapshot, - cwd, - ); - assert.equal(disabled, machineSnapshot); - assert.equal(query.mock.calls.length, 2); - }).pipe(Effect.scoped), - ); - - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = yield* path.fromFileUrl( - new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), - ); - const invocationPath = path.join(tempDir, "invocation.json"); - // The probe aborts the SDK without awaiting the child's exit, and on - // Windows a directory that is still some process's cwd cannot be - // removed. Keep the workspace outside the scoped directory and let it - // go with a retrying removal once the child has gone. - const workspaceCwd = yield* fs.makeTempDirectory({ prefix: "t3-claude-probe-cwd-" }); - // Node's own retry rather than an Effect schedule: it.effect runs on a - // TestClock, so a scheduled retry would wait for time nobody advances. - yield* Effect.addFinalizer(() => - Effect.promise(() => - NodeFSP.rm(workspaceCwd, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 250, - }).catch(() => undefined), - ), - ); - - const capabilities = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: executablePath }), - { - ...process.env, - T3_PROBE_INVOCATION_PATH: invocationPath, - ENABLE_CLAUDEAI_MCP_SERVERS: "true", - }, - workspaceCwd, - ); - - assert.deepEqual(capabilities, { - email: "dev@example.com", - subscriptionType: "pro", - tokenSource: "oauth", - apiKeySource: undefined, - apiProvider: undefined, - slashCommands: [ - { - name: "review", - description: "Review changes", - input: { hint: "[path]" }, - }, - ], - usage: { - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - }, - usageCheckedAt: "1970-01-01T00:00:00.000Z", - }); - - const invocation = JSON.parse(yield* fs.readFileString(invocationPath)) as { - readonly args: ReadonlyArray; - readonly cwd: string; - readonly connectorEnv: string; - readonly mcpConfig: unknown; - }; - assert.equal(invocation.cwd, yield* fs.realPath(workspaceCwd)); - assert.equal(invocation.connectorEnv, "false"); - assert.equal(invocation.args.includes("--strict-mcp-config"), true); - assert.equal(invocation.args.includes("--mcp-config"), false); - assert.equal(invocation.mcpConfig, undefined); - - assert.equal(invocation.args.includes("--setting-sources=user,project,local"), true); - - const settingsFlagIndex = invocation.args.indexOf("--settings"); - assert.notEqual(settingsFlagIndex, -1); - const flagSettings = JSON.parse(invocation.args[settingsFlagIndex + 1] ?? "{}") as { - readonly disableAllHooks?: boolean; - }; - assert.equal(flagSettings.disableAllHooks, true); - }).pipe(Effect.scoped), - ); - it.effect("falls back to the stored .claude.json account email when init omits it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -395,90 +92,3 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }).pipe(Effect.scoped), ); }); - -it.effect("preserves initialized capabilities when optional usage times out", () => - Effect.gen(function* () { - const usageStarted = yield* Deferred.make(); - let abortSignal: AbortSignal | undefined; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - abortSignal = options?.abortController?.signal; - return { - initializationResult: async () => ({ - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { - Deferred.doneUnsafe(usageStarted, Effect.void); - return new Promise(() => {}); - }, - } as ReturnType; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.forkChild); - yield* Deferred.await(usageStarted); - yield* TestClock.adjust("4 seconds"); - const capabilities = yield* Fiber.join(probe); - assert.equal(capabilities?.email, "dev@example.com"); - assert.equal(capabilities?.subscriptionType, "pro"); - assert.equal(capabilities?.tokenSource, "oauth"); - assert.deepEqual(capabilities?.slashCommands, [ - { name: "review", description: "Review changes", input: { hint: "[path]" } }, - ]); - assert.equal(capabilities?.usage, undefined); - assert.equal(abortSignal?.aborted, true); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("fails a hung initialization probe with a timeout", () => - Effect.gen(function* () { - const initializationStarted = yield* Deferred.make(); - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => { - Deferred.doneUnsafe(initializationStarted, Effect.void); - return new Promise(() => {}); - }, - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ - rate_limits_available: true, - rate_limits: {}, - }), - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.result, Effect.forkChild); - - yield* Deferred.await(initializationStarted); - yield* TestClock.adjust("25 seconds"); - const result = yield* Fiber.join(probe); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) assert.isTrue(Cause.isTimeoutError(result.failure)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect("asks for usage without the local transcript scan", () => - Effect.gen(function* () { - let usageOptions: unknown; - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: async () => ({ - account: { email: "dev@example.com", subscriptionType: "max", tokenSource: "oauth" }, - commands: [], - }), - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async (options?: unknown) => { - usageOptions = options; - return { rate_limits_available: true, rate_limits: null }; - }, - }) as unknown as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - yield* probeClaudeCapabilities(decodeClaudeSettings({ binaryPath: "claude" })); - assert.deepEqual(usageOptions, { skipBehaviors: true }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index bec1c8fb9576..2363c9aa86a3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,7 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { formatClaudeVersionUpgradeMessage, @@ -197,37 +196,6 @@ describe("Claude model catalog", () => { ); }); - it("rejects malformed adapter mappings", () => { - const base = manifest(); - const malformed: ModelManifestData = { - ...base, - providers: { - ...base.providers, - claudeAgent: { - ...base.providers!.claudeAgent!, - profiles: { - ...base.providers!.claudeAgent!.profiles, - synthetic: { - ...base.providers!.claudeAgent!.profiles.synthetic!, - adapter: { claudeCode: { effortMap: { extreme: 123 } } }, - }, - }, - }, - }, - }; - assert.isFalse(hasValidClaudeManifestAdapters(malformed)); - }); - - it("preserves only SDK effort levels for uncatalogued models", () => { - const catalog = catalogFromManifest(manifest()); - for (const effort of ["low", "medium", "high", "xhigh", "max"]) { - assert.strictEqual(resolveClaudeCatalogEffort(catalog, "custom", ` ${effort} `), effort); - } - for (const effort of [undefined, null, "", " ", "brutal", "extreme", "HIGH", "ultrathink"]) { - assert.isUndefined(resolveClaudeCatalogEffort(catalog, "custom", effort)); - } - }); - it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { const catalog = scopeClaudeModelCatalog(catalogFromManifest(manifest()), [ "synthetic", diff --git a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts index a04672d88c3c..762f919dc39c 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts @@ -3,7 +3,7 @@ import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts"; export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; -export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; +const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; const effort = { id: "effort", diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 2b169d472add..a96944ccf329 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -203,7 +203,7 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; // Native CLI startup on Windows can exceed the shared probe deadline. const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000; -export const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ +const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [ "user", "project", "local", diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index d2e5b78624f7..fe3701dbedae 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -10,14 +10,12 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; -import * as TestClock from "effect/testing/TestClock"; import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; import { FetchHttpClient } from "effect/http"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; -import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; @@ -455,33 +453,6 @@ it.effect.each(["identity", "sharing"] as const)( }), ), ); -it.effect("retains the callback host and path after controller recreation and token removal", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const first = yield* makeHarnessFor(instanceId, bytes); - yield* first.signIn; - yield* first.phase("succeeded"); - const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); - yield* first.auth.revoke; - const restarted = yield* makeHarnessFor(instanceId, bytes); - yield* restarted.signIn; - yield* restarted.phase("succeeded"); - assert.strictEqual( - restarted.authorizationRequests[0]!.searchParams.get("client_id"), - "oaiapp_test", - ); - assertSameCallback( - restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), - redirectUri, - ); - assert.strictEqual( - restarted.exchanges[0]!.get("redirect_uri"), - restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), - ); - }), - ), -); it.effect("reauthorizes on an available port when the original callback port is occupied", () => provision( Effect.gen(function* () { @@ -514,30 +485,6 @@ it.effect("reauthorizes on an available port when the original callback port is }), ), ); - -it.effect("registers a fresh client when the original registration is no longer stored", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const h = yield* makeHarnessFor(instanceId, bytes); - yield* h.signIn; - yield* h.phase("succeeded"); - for (const [key, value] of bytes) { - const record = JSON.parse(new TextDecoder().decode(value)); - if (record.profiles) bytes.delete(key); - } - h.setCallbackClientId("oaiapp_replacement"); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", - ); - assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); - }), - ), -); it.effect("reauthorizes a registration without persisting its original port", () => provision( Effect.gen(function* () { @@ -716,20 +663,6 @@ it.effect( }), ), ); -it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment"); - yield* h.signIn; - assert.include( - h.callbackResponses[0]!.body, - 'content="1;url=http://localhost:7001/welcome#agents:test-environment"', - ); - assert.notInclude(h.callbackResponses[0]!.body, "never-forward"); - }), - ), -); it.effect( "does not redirect to an arbitrary return URL or claim success after verification fails", () => @@ -892,60 +825,6 @@ it.effect( }), ), ); -it.effect("revoked connection clears tokens but preserves registration for reconnect", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.seedExpired; - h.setRevoked(); - yield* h.auth.access.pipe(Effect.result); - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.deepEqual(h.storedRecords(), [ - { - profiles: [ - { - clientId: "oaiapp_test", - connectionLabel: "Connection 1", - sharingEnabled: true, - redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), - subject: "user-test", - email: "hidden@example.test", - }, - ], - lastClientId: "oaiapp_test", - }, - ]); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); - }), - ), -); - -it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.auth.revoke; - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.strictEqual(h.storedRecords().length, 1); - h.setCallbackClientId("oaiapp_new_workspace"); - h.setIdentity("new-workspace-user", "new@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[1]!.searchParams.get("client_id"), - "dynamic_agent_client", - ); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user"); - }), - ), -); it.effect("rejects mismatched callback state before any token exchange or credential write", () => provision( @@ -997,25 +876,6 @@ it.effect.each([ ), ); -it.effect("returns successful desktop sign-in to the original Welcome step", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment"); - yield* h.signIn; - yield* h.phase("succeeded"); - assert.include( - h.callbackResponses[0]!.body, - 'content="1;url=t3code-dev://app/welcome#agents:test-environment"', - ); - assert.include( - h.callbackResponses[0]!.body, - 'href="t3code-dev://app/welcome#agents:test-environment"', - ); - }), - ), -); - it.effect( "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization", () => @@ -1207,44 +1067,6 @@ it.effect( ), ); -it.effect("fresh sign-in to the same identity preserves separate registration profiles", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setIdentity("other-user", "other@example.test"); - h.setCallbackClientId("oaiapp_other_account"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - h.setIdentity("user-test", "hidden@example.test"); - h.setCallbackClientId("oaiapp_replacement"); - yield* h.changeAccount; - const changed = yield* h.phase("succeeded"); - assert.deepEqual( - changed - .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) - .map((method) => method.id), - [ - "chatgpt-profile:oaiapp_replacement", - "chatgpt-profile:oaiapp_other_account", - "chatgpt-profile:oaiapp_test", - ], - ); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); - const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri"); - yield* h.auth.controller.logout(Effect.void); - yield* h.reconnectProfile("oaiapp_replacement"); - yield* h.phase("succeeded"); - assert.strictEqual( - h.authorizationRequests[3]!.searchParams.get("client_id"), - "oaiapp_replacement", - ); - assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri); - }), - ), -); - it.effect("preserves legacy profiles with the same email and subject", () => provision( Effect.gen(function* () { @@ -1387,38 +1209,6 @@ it.effect("declined sharing on an older profile does not replace the active acco ), ); -it.effect( - "Disconnect preserves a remote profile's callback URI for client-delivered reconnect", - () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - const first = yield* h.startRemote(); - yield* h.auth.controller.complete("owner", { - flowId: first.waiting.flowId!, - callbackUrl: first.callbackUrl, - }); - yield* h.phase("succeeded"); - yield* h.auth.controller.logout(Effect.void); - const second = yield* h.startRemote(); - assert.strictEqual( - new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), - "oaiapp_test", - ); - assertSameCallback( - new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"), - new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"), - ); - yield* h.auth.controller.complete("owner", { - flowId: second.waiting.flowId!, - callbackUrl: second.callbackUrl, - }); - yield* h.phase("succeeded"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); - }), - ), -); - it.effect.each([ "invalid_grant", "invalid_refresh_token", @@ -1482,23 +1272,6 @@ it.effect( ), ); -it.live("logout retries temporary revocation failures and reports local-only sign-out", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setRevocationStatus(503); - const state = yield* h.auth.controller.logout(Effect.void); - assert.lengthOf(h.revocations, 3); - assert.strictEqual(state.phase, "idle"); - assert.include(state.message!, "Remote revocation could not be confirmed"); - assert.isTrue(Option.isNone(yield* h.auth.read)); - assert.isFalse(h.storedRecords().some((record) => record.idToken)); - }), - ), -); - it.effect( "reauth hints come from the selected profile, including after another profile logs out", () => @@ -1648,26 +1421,6 @@ it.effect( ), ); -it.effect("keeps the active connection first when another profile declines sharing", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_identity_only"); - h.setIdentity("other-user", "other@example.test"); - h.declineSharing(); - yield* h.changeAccount; - const state = yield* h.phase("failed"); - assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test"); - const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")); - assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test"); - assert.include(profiles[0]!.name, "Connection 1"); - assert.include(profiles[1]!.name, "Connection 2"); - }), - ), -); - it.effect("primary completes OAuth and destination imports and owns the refresh session", () => provision( Effect.gen(function* () { @@ -1840,66 +1593,6 @@ it.effect( ), ); -it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - h.setInvalidNonce(); - yield* h.signIn; - yield* h.phase("failed"); - const { waiting } = yield* h.startRemote(); - yield* h.auth.controller.cancel("owner", waiting.flowId!); - yield* h.phase("cancelled"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => properties?.outcome), - ["succeeded", "failed", "cancelled"], - ); - assert.strictEqual( - h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length, - 3, - ); - assert.strictEqual(completed[1]?.properties?.failureStage, "verify"); - for (const { properties } of completed) { - assert.isNumber(properties?.durationMs); - assert.strictEqual(properties?.flow, "direct"); - assert.deepStrictEqual( - Object.keys(properties!).sort(), - [ - ...(properties?.outcome === "succeeded" - ? [ - "accountCountScope", - "connectedAccountCount", - "connectedConnectionCount", - "savedConnectionCount", - "unidentifiedConnectedConnectionCount", - ] - : []), - "callbackMode", - "durationMs", - ...(properties?.failureStage ? ["failureStage"] : []), - "flow", - "intent", - "outcome", - ].sort(), - ); - } - const serialized = JSON.stringify(h.analyticsEvents); - for (const secret of [ - "hidden@example.test", - "oaiapp_test", - "user-test", - "access_token", - "refresh_token", - ]) { - assert.notInclude(serialized, secret); - } - }), - ), -); - it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => provision( Effect.gen(function* () { @@ -1911,142 +1604,3 @@ it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => }), ), ); - -it.effect("counts accounts separately from saved connections across additions and reconnects", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - yield* h.signIn; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_same_account"); - h.setIdentity("another-client-subject", "HIDDEN@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - yield* h.auth.controller.logout(Effect.void); - h.setCallbackClientId("oaiapp_test"); - h.setIdentity("user-test", "hidden@example.test"); - yield* h.reconnectProfile("oaiapp_test"); - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.deepStrictEqual( - completed.map(({ properties }) => [ - properties?.connectedAccountCount, - properties?.connectedConnectionCount, - properties?.savedConnectionCount, - ]), - [ - [1, 1, 1], - [1, 1, 1], - [1, 2, 2], - [2, 3, 3], - [1, 2, 3], - ], - ); - assert.isTrue( - completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0), - ); - assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test"); - }), - ), -); - -it.effect("includes accounts from other Codex instances in the environment", () => - provision( - Effect.gen(function* () { - const bytes = new Map(); - const personal = yield* makeHarnessFor(instanceId, bytes); - const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes); - yield* personal.signIn; - yield* personal.phase("succeeded"); - work.setCallbackClientId("oaiapp_work"); - work.setIdentity("work-user", "work@example.test"); - yield* work.signIn; - yield* work.phase("succeeded"); - const completed = work.analyticsEvents.find( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.accountCountScope, "environment"); - assert.strictEqual(completed?.properties?.connectedAccountCount, 2); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.savedConnectionCount, 2); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerInstances: { - [instanceId]: { driver: "codex", enabled: true }, - [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true }, - }, - }), - ), - ), - ), -); - -it.effect("an unreadable unrelated profile omits counts without failing sign-in", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid")); - yield* h.signIn; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed?.properties?.outcome, "succeeded"); - assert.notProperty(completed?.properties ?? {}, "connectedAccountCount"); - }).pipe(Effect.provide(ServerSettings.layerTest())), - ), -); - -it.effect("reports connections without an email separately from identifiable accounts", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.signIn; - yield* h.phase("succeeded"); - const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, h.secrets), - ); - yield* store.set( - new TextEncoder().encode( - JSON.stringify({ - activeClientId: "oaiapp_test", - sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }], - }), - ), - ); - h.setCallbackClientId("oaiapp_other_account"); - h.setIdentity("other-user", "other@example.test"); - yield* h.changeAccount; - yield* h.phase("succeeded"); - const completed = h.analyticsEvents.findLast( - ({ event }) => event === "chatgpt.auth.completed", - ); - assert.strictEqual(completed?.properties?.connectedAccountCount, 1); - assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); - assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1); - }), - ), -); - -it.effect("records an expired auth outcome when sign-in reaches its deadline", () => - provision( - Effect.gen(function* () { - const h = yield* makeHarness; - yield* h.startRemote(); - yield* TestClock.adjust(300_001); - assert.include((yield* h.phase("failed")).message ?? "", "expired"); - const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); - assert.strictEqual(completed.length, 1); - assert.strictEqual(completed[0]?.properties?.outcome, "expired"); - assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000); - }), - ), -); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 0808d89db9d6..14a613967c11 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -8,7 +8,6 @@ import { describe, it } from "vite-plus/test"; import { buildCodexAdditionalContext, - buildCodexDeveloperInstructions, CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES, } from "./CodexDeveloperInstructions.ts"; @@ -62,57 +61,6 @@ describe("buildCodexAdditionalContext", () => { }); describe("buildCodexDeveloperInstructions", () => { - it("appends runtime info after the mode instructions", () => { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - - NodeAssert.match( - buildCodexDeveloperInstructions("default"), - /^# Collaboration Mode: Default/, - ); - NodeAssert.match(instructions, /T3 Code/); - NodeAssert.match(instructions, /Codex harness/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); - }); - - it("describes Markdown media support in the runtime context in both modes", () => { - { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images and videos.*Markdown.*<\/runtime_info>/, - ); - } - }); - - it("includes runtime info alongside plan mode instructions", () => { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - - NodeAssert.match(buildCodexDeveloperInstructions("plan"), /^# Plan Mode/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); - }); - - it("varies with the model and effort of each turn", () => { - const first = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - const second = runtimeInstructions({ - model: "gpt-5.4", - reasoningEffort: "high", - }); - - NodeAssert.notEqual(first, second); - }); - it("flattens multiline metadata into single-line runtime info", () => { const instructions = runtimeInstructions({ model: "gpt\n5.3\ncodex", @@ -124,43 +72,6 @@ describe("buildCodexDeveloperInstructions", () => { }); }); -describe("T3 browser developer instructions", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; - - it("prefers the product-native preview tools in both collaboration modes", () => { - { - const instructions = toolInstructions(runtime, true); - NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); - } - }); - - it("omits the browser block entirely when the preview tools are not attached", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = toolInstructions(runtime, false); - NodeAssert.doesNotMatch(instructions, /preview_status/); - NodeAssert.doesNotMatch(instructions, /preview_open/); - NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); - NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); - NodeAssert.match(buildCodexDeveloperInstructions(mode), //); - NodeAssert.match(buildCodexDeveloperInstructions(mode), /<\/collaboration_mode>/); - } - }); - - it("tracks the turn's MCP configuration rather than defaulting to on", () => { - NodeAssert.match(toolInstructions(runtime, true), /preview_open/); - NodeAssert.doesNotMatch(toolInstructions(runtime, false), /preview_open/); - }); -}); - function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } -function toolInstructions( - runtime: Parameters[0], - available: boolean, -) { - return buildCodexAdditionalContext(runtime, available).t3_code_tools?.value ?? ""; -} diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts index 4eab3b32a12a..849b3c928af8 100644 --- a/apps/server/src/provider/CodexInstallation.test.ts +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -2,22 +2,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ModelManifest from "./ModelManifest.ts"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; -import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/http"; -import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as CodexInstallation from "./CodexInstallation.ts"; @@ -190,17 +186,6 @@ it.effect.each([ expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("falls back to a managed download when the reused local executable disappears", () => - Effect.gen(function* () { - const h = yield* makeHarness({ local: { version: "0.156.1" } }); - expect((yield* h.installation.resolve()).source).toBe("local"); - yield* h.fs.remove(h.localBinaryPath); - yield* h.installation.start; - expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); - expect((yield* h.installation.resolve()).source).toBe("managed"); - expect(h.downloads()).toBe(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); const compileNative = Effect.fn("test.compileNative")(function* ( executablePath: string, @@ -217,58 +202,6 @@ const compileNative = Effect.fn("test.compileNative")(function* ( expect(yield* compiler.exitCode).toBe(0); }, Effect.scoped); -it.effect.each([ - { withHost: false, marker: null }, - { withHost: true, marker: null }, - { withHost: false, marker: ".bunx" }, - { withHost: false, marker: ".shim" }, -])("requires the native host or a known package-manager launcher: %j", ({ withHost, marker }) => - Effect.gen(function* () { - const h = yield* makeHarness({ - local: { version: "0.156.1" }, - platform: marker ? "win32" : "darwin", - }); - yield* compileNative( - h.localBinaryPath, - `#include -#include -int main(int argc, char **argv) { - if (argc == 2 && strcmp(argv[1], "--version") == 0) { - puts("codex-cli 0.156.1"); - return 0; - } - return !(argc == 3 && strcmp(argv[1], "app-server") == 0 && strcmp(argv[2], "--help") == 0); -} -`, - ); - if (withHost) { - const packageDirectory = `${h.baseDir}/package`; - yield* h.fs.makeDirectory(packageDirectory); - yield* h.fs.rename(h.localBinaryPath, `${packageDirectory}/codex`); - yield* h.fs.copyFile(`${packageDirectory}/codex`, `${packageDirectory}/codex-code-mode-host`); - yield* h.fs.symlink(`${packageDirectory}/codex`, h.localBinaryPath); - } - if (marker) { - yield* h.fs.writeFileString( - h.localBinaryPath.replace(/\.exe$/u, marker), - "launcher metadata", - ); - } - const reusesLocal = withHost || marker !== null; - const started = yield* h.installation.start; - if (reusesLocal) { - expect(started).toMatchObject({ - source: "local", - phase: "succeeded", - executablePath: h.localBinaryPath, - }); - } - expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); - expect((yield* h.installation.resolve()).source).toBe(reusesLocal ? "local" : "managed"); - expect(h.downloads()).toBe(reusesLocal ? 0 : 1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - it.effect("rechecks compatibility after the local executable is replaced", () => Effect.gen(function* () { const h = yield* makeHarness({ local: { version: "0.156.1" } }); @@ -486,119 +419,4 @@ it.effect("keeps an activated runtime when a later update fails verification", ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it("publishes complete packages for all supported platforms", () => { - for (const platform of ["darwin", "linux", "win32"] as const) - for (const arch of ["x64", "arm64"]) - expect(CodexInstallation.resolveCodexReleaseAsset(platform, arch)?.url).toContain( - "codex-package-", - ); - expect(CodexInstallation.resolveCodexReleaseAsset("linux", "riscv64")).toBeNull(); -}); -// Live clock: staleness compares real filesystem mtimes. -it.live("startup removes only stale staging directories", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" }); - const managed = `${baseDir}/tools/codex`; - const stale = ((yield* Clock.currentTimeMillis) - 25 * 60 * 60_000) / 1000; - for (const name of [".install-stale", ".active-stale", ".install-live"]) { - yield* fs.makeDirectory(`${managed}/${name}`, { recursive: true }); - } - yield* fs.utimes(`${managed}/.install-stale`, stale, stale); - yield* fs.utimes(`${managed}/.active-stale`, stale, stale); - const h = yield* makeHarness({ baseDir }); - expect(yield* fs.readDirectory(managed)).toEqual([".install-live"]); - expect((yield* h.installation.state).canRemove).toBe(true); - - yield* fs.remove(`${managed}/.install-live`, { recursive: true }); - yield* fs.makeDirectory(`${managed}/.install-stale`); - yield* fs.utimes(`${managed}/.install-stale`, stale, stale); - const fresh = yield* makeHarness({ baseDir }); - expect(yield* fs.readDirectory(managed)).toEqual([]); - expect((yield* fresh.installation.state).canRemove).toBe(false); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -const processHandle = (stdout: string) => - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(stdout)), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -const windowsPackage: Record = { - "bin/codex.exe": "codex", - "bin/codex-code-mode-host.exe": "host", - "codex-path/rg.exe": "rg", - "codex-package.json": JSON.stringify({ - layoutVersion: 1, - version: asset.version, - target: asset.target, - entrypoint: "bin/codex.exe", - }), -}; // Stands in for Windows' bsdtar: CRLF listings, and extraction writes the Windows layout. -const makeWindowsTar = (fs: FileSystem.FileSystem, commands: Array) => - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const { command: executable, args } = command as unknown as { - readonly command: string; - readonly args: ReadonlyArray; - }; - commands.push(executable); - const names = Object.keys(windowsPackage); - if (args[0] === "-tzf") return processHandle(`${names.join("\r\n")}\r\n`); - if (args[0] === "-tvzf") - return processHandle(`${names.map((name) => `-rwxr-xr-x 0 0 ${name}`).join("\r\n")}\r\n`); - const runtime = args[3] ?? ""; - for (const [name, contents] of Object.entries(windowsPackage)) { - yield* fs.makeDirectory(`${runtime}/${name.split("/").slice(0, -1).join("/")}`, { - recursive: true, - }); - yield* fs.writeFileString(`${runtime}/${name}`, contents); - } - return processHandle(""); - }).pipe(Effect.orDie), - ); - -it.effect("installs on Windows via System32 tar with CRLF output and a locked runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const commands: Array = []; - const h = yield* makeHarness({ - platform: "win32", - spawner: makeWindowsTar(fs, commands), - runtimeRenameFailures: 3, - }); - yield* h.installation.start; - // Each failed attempt has scheduled its retry before the test fiber resumes. - for (let attempt = 0; attempt < 3; attempt++) { - yield* Queue.take(h.failedRenames); - yield* TestClock.adjust("100 millis"); - } - expect(yield* terminalState(h.installation)).toMatchObject({ phase: "succeeded" }); - expect(commands).toEqual([ - "C:\\Windows\\System32\\tar.exe", - "C:\\Windows\\System32\\tar.exe", - "C:\\Windows\\System32\\tar.exe", - ]); - const path = yield* Path.Path; - expect((yield* h.installation.resolve()).executablePath).toBe( - path.join(h.baseDir, "tools", "codex", "0.156.1", "bin", "codex.exe"), - ); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); -it.effect("does not retry a failed runtime rename outside Windows", () => - Effect.gen(function* () { - const h = yield* makeHarness({ runtimeRenameFailures: 1 }); - yield* h.installation.start; - expect((yield* terminalState(h.installation)).phase).toBe("failed"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index 3284b30544cc..e481f95e3c57 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -96,7 +96,7 @@ const RELEASES: Readonly> = { archiveBytes: 153839991, }, }; -export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => +const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => RELEASES[`${platform}-${arch}`] ?? null; export class CodexInstallationError extends Schema.TaggedError()( "CodexInstallationError", diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6..8034e910d67a 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. -import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -18,36 +17,9 @@ import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { codexAppServerArgs } from "./codexLaunchArgs.ts"; -import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeSettings = Schema.decodeSync(CodexSettings); -it.effect("managed home defaults to the global Codex home and honors configured home paths", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const primary = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex"), - decodeSettings({}), - ); - assert.equal(primary.sharedHomePath, path.join(NodeOS.homedir(), ".codex")); - assert.equal(primary.mode, "direct"); - const additional = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex-work"), - decodeSettings({}), - ); - assert.equal(additional.sharedHomePath, primary.sharedHomePath); - assert.equal(additional.mode, "authOverlay"); - const configured = yield* resolveManagedCodexHomeLayout( - "/t3-state", - ProviderInstanceId.make("codex-work"), - decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }), - ); - assert.equal(configured.sharedHomePath, "/custom/shared"); - assert.equal(configured.effectiveHomePath, "/custom/shadow"); - }).pipe(Effect.provide(NodeServices.layer)), -); it.effect.each( (["managed", "local"] as const).flatMap((source) => (["primary", "additional"] as const).map((account) => ({ source, account })), diff --git a/apps/server/src/provider/CodexMcpElicitation.test.ts b/apps/server/src/provider/CodexMcpElicitation.test.ts index c0655faf0d67..66dd7ddb6e7d 100644 --- a/apps/server/src/provider/CodexMcpElicitation.test.ts +++ b/apps/server/src/provider/CodexMcpElicitation.test.ts @@ -45,12 +45,6 @@ describe("Codex MCP elicitation approvals", () => { }); }); - it("extracts the app name from a Computer Use request without metadata", () => { - const { _meta, ...requestWithoutMetadata } = request; - - NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); - }); - it("returns the accepted form option to Codex", () => { NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { action: "accept", @@ -66,6 +60,7 @@ describe("Codex MCP elicitation approvals", () => { }); }); + // regression: #8058 show Codex app access approval prompts it("returns persistent approval in the MCP response", () => { NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { action: "accept", @@ -74,18 +69,6 @@ describe("Codex MCP elicitation approvals", () => { }); }); - it("returns rejection without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { - action: "decline", - }); - }); - - it("returns cancellation without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { - action: "cancel", - }); - }); - it("supports boolean permanent-approval fields", () => { const booleanRequest = { ...request, diff --git a/apps/server/src/provider/CodexProvider.catalog.test.ts b/apps/server/src/provider/CodexProvider.catalog.test.ts deleted file mode 100644 index fe859429d30b..000000000000 --- a/apps/server/src/provider/CodexProvider.catalog.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { CodexSettings } from "@t3tools/contracts"; -import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; - -import { checkCodexProviderStatus } from "./CodexProvider.ts"; - -/** - * Stand-in for Codex 0.162's app-server catalog behavior: - * - `model/list` answers from `CODEX_HOME/models_cache.json` while it is fresh. - * - A startup refresh runs before `initialize`, so it uses the originator set - * at spawn (the override env var, else the plain CLI's `codex_cli_rs`). - * - The backend grants Ultrafast only to entitled accounts on app originators. - */ -const FAKE_CODEX_APP_SERVER = String.raw`#!/usr/bin/env node -const fs = require("node:fs"); -const path = require("node:path"); -const readline = require("node:readline"); -const cachePath = path.join(process.env.CODEX_HOME, "models_cache.json"); -const originator = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? "codex_cli_rs"; -const ultrafast = process.env.FAKE_ULTRAFAST_ENTITLED === "1" && originator !== "codex_cli_rs"; -const model = (tiers) => ({ - id: "gpt-6.1-sol", model: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", description: "", - hidden: false, isDefault: true, defaultReasoningEffort: "high", - supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], - serviceTiers: tiers.map((id) => ({ id, name: id === "priority" ? "Fast" : id, description: "" })), -}); -setTimeout(() => { - const tiers = ultrafast ? ["priority", "ultrafast"] : ["priority"]; - fs.writeFileSync(cachePath, JSON.stringify({ fetched_at: new Date().toISOString(), models: [model(tiers), { ...model(tiers), id: "gpt-6-luna", model: "gpt-6-luna", isDefault: false }] })); -}, 300); -const write = (message) => process.stdout.write(JSON.stringify(message) + "\n"); -readline.createInterface({ input: process.stdin }).on("line", (line) => { - const { id, method, params } = JSON.parse(line); - if (id === undefined) return; - if (method === "initialize") { - write({ id, result: { userAgent: "codex/0.162.0", codexHome: process.env.CODEX_HOME, platformFamily: "unix", platformOs: "macos" } }); - } else if (method === "account/read") { - write({ id, result: { account: { type: "chatgpt", email: "test@example.com", planType: "promax" }, requiresOpenaiAuth: false } }); - } else if (method === "skills/list") { - write({ id, result: { data: [] } }); - } else if (method === "config/read") { - fs.writeFileSync(path.join(process.env.CODEX_HOME, "config-request.json"), JSON.stringify(params)); - const tier = process.env.FAKE_SERVICE_TIER; - if (tier === "hang") return; - if (tier === "error") write({ id, error: { code: -32601, message: "unsupported" } }); - else write({ id, result: { config: tier === "missing" ? {} : { service_tier: tier }, origins: {}, layers: null } }); - } else if (method === "model/list") { - write({ id, result: { data: JSON.parse(fs.readFileSync(cachePath, "utf8")).models, nextCursor: null } }); - } else { - write({ id, error: { code: -32601, message: "unsupported" } }); - } -}); -`; - -const decodeCodexSettings = Schema.decodeSync(CodexSettings); - -const probeCatalog = Effect.fn("probeCatalog")(function* ( - ultrafastEntitled: boolean, - serviceTier = "error", -) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-catalog-" }); - const binaryPath = path.join(dir, "codex"); - yield* fs.writeFileString(binaryPath, FAKE_CODEX_APP_SERVER); - yield* fs.chmod(binaryPath, 0o755); - - yield* fs.writeFileString( - path.join(dir, "models_cache.json"), - JSON.stringify({ - fetched_at: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })), - models: [ - { - id: "gpt-6.1-sol", - model: "gpt-6.1-sol", - displayName: "GPT-6.1 Sol", - description: "", - hidden: false, - isDefault: true, - defaultReasoningEffort: "high", - supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }], - serviceTiers: (ultrafastEntitled ? ["priority"] : ["priority", "ultrafast"]).map( - (id) => ({ id, name: id === "priority" ? "Fast" : id, description: "" }), - ), - }, - ], - }), - ); - - const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _ambient, ...environment } = process.env; - const provider = yield* checkCodexProviderStatus( - decodeCodexSettings({ binaryPath, homePath: dir }), - undefined, - { - ...environment, - FAKE_ULTRAFAST_ENTITLED: ultrafastEntitled ? "1" : "0", - FAKE_SERVICE_TIER: serviceTier, - }, - ); - const configRequest = yield* fs.readFileString(path.join(dir, "config-request.json")); - assert.deepStrictEqual(JSON.parse(configRequest), { includeLayers: false }); - assert.equal(provider.status, "ready"); - return provider; -}); - -const solServiceTiers = Effect.fn("solServiceTiers")(function* (ultrafastEntitled: boolean) { - const provider = yield* probeCatalog(ultrafastEntitled); - const serviceTier = provider.models - .find((model) => model.slug === "gpt-6.1-sol") - ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "serviceTier"); - return serviceTier?.type === "select" ? serviceTier.options.map((option) => option.id) : []; -}); - -it.layer(NodeServices.layer, { excludeTestServices: true })("Codex catalog discovery", (it) => { - it.effect("keeps Ultrafast when another Codex client wrote the shared catalog cache", () => - Effect.gen(function* () { - assert.deepStrictEqual(yield* solServiceTiers(true), ["default", "priority", "ultrafast"]); - }).pipe(Effect.scoped), - ); - - it.effect("drops Ultrafast once the account is no longer entitled to it", () => - Effect.gen(function* () { - assert.deepStrictEqual(yield* solServiceTiers(false), ["default", "priority"]); - }).pipe(Effect.scoped), - ); - - it.effect("displays the inherited global Fast tier on every discovered model", () => - Effect.gen(function* () { - for (const tier of ["fast", "priority"]) { - const provider = yield* probeCatalog(false, tier); - assert.deepStrictEqual( - provider.models.map((model) => model.slug), - ["gpt-6.1-sol", "gpt-6-luna"], - ); - for (const model of provider.models) { - const descriptors = model.capabilities?.optionDescriptors ?? []; - const serviceTier = descriptors.find((descriptor) => descriptor.id === "serviceTier"); - assert.equal(serviceTier?.type, "select"); - if (serviceTier?.type !== "select") return; - assert.equal(getProviderOptionCurrentLabel(serviceTier), "Fast"); - assert.deepStrictEqual( - serviceTier.options.filter((option) => option.isDefault).map((option) => option.id), - ["priority"], - ); - assert.equal( - descriptors.find((descriptor) => descriptor.id === "reasoningEffort")?.currentValue, - "high", - ); - } - assert.equal(provider.models.find((model) => model.isDefault)?.slug, "gpt-6.1-sol"); - } - }).pipe(Effect.scoped), - ); - - it.effect( - "preserves the catalog when global tier config is missing, unknown, failing, or stalled", - () => - Effect.gen(function* () { - const baseline = yield* probeCatalog(false, "missing"); - const serviceTier = baseline.models[0]?.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "serviceTier", - ); - assert.equal(getProviderOptionCurrentLabel(serviceTier), "Standard"); - for (const config of ["unsupported", "error", "hang"]) { - const provider = yield* probeCatalog(false, config); - assert.deepStrictEqual(provider.models, baseline.models, config); - } - }).pipe(Effect.scoped, Effect.timeout("8 seconds")), - ); -}); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 7474ce47c19c..30f63b21f318 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,23 +1,6 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { CodexSettings } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Queue from "effect/Queue"; -import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { - applyPreferredCodexDefaultModel, - checkCodexProviderStatus, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -145,15 +128,6 @@ it("marks the most preferred available model as default", () => { ); }); -it("prefers sol over terra when both are available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.6-sol"); -}); - it("ranks qualified Codex models while preserving their wire ids", () => { const models = applyPreferredCodexDefaultModel([ { @@ -171,15 +145,6 @@ it("ranks qualified Codex models while preserving their wire ids", () => { ); }); -it("keeps Codex's own default when no preferred model is available", () => { - const models = applyPreferredCodexDefaultModel([ - { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities: null }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, isDefault: true, capabilities: null }, - ]); - - assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); -}); - it("ignores custom models that shadow a preferred slug", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.6-sol", name: "gpt-5.6-sol", isCustom: true, capabilities: null }, @@ -188,211 +153,3 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); - -const decodeCodexSettings = Schema.decodeSync(CodexSettings); -const decodeRequest = Schema.decodeUnknownSync( - Schema.fromJsonString( - Schema.Struct({ - id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Number])), - method: Schema.String, - }), - ), -); -const account = { - account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, - requiresOpenaiAuth: false, -} as const; - -const startProbe = Effect.fn("startProbe")(function* (hang: ReadonlyArray) { - const fs = yield* FileSystem.FileSystem; - const homePath = yield* fs.makeTempDirectoryScoped(); - yield* fs.writeFileString( - `${homePath}/models_cache.json`, - JSON.stringify({ fetched_at: DateTime.formatIso(yield* DateTime.now) }), - ); - const output = yield* Queue.unbounded(); - const requests = new Map( - yield* Effect.forEach( - ["account/read", "skills/list", "model/list/1", "model/list/2", "account/rateLimits/read"], - (method) => - Deferred.make().pipe(Effect.map((signal) => [method, signal] as const)), - ), - ); - const stopped = yield* Deferred.make(); - const reply = (id: string | number, result: unknown) => - Queue.offer(output, new TextEncoder().encode(`${JSON.stringify({ id, result })}\n`)); - let modelRequests = 0; - const spawner = ChildProcessSpawner.make(() => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => Deferred.succeed(stopped, undefined)); - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.await(stopped).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - isRunning: Deferred.isDone(stopped).pipe(Effect.map((done) => !done)), - kill: () => Deferred.succeed(stopped, undefined).pipe(Effect.asVoid), - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((bytes: Uint8Array) => - Effect.forEach( - new TextDecoder().decode(bytes).split("\n").filter(Boolean), - (line) => - Effect.gen(function* () { - const { id, method } = decodeRequest(line); - if (id === undefined) return; - const request = method === "model/list" ? `${method}/${++modelRequests}` : method; - const signal = requests.get(request); - if (signal) yield* Deferred.succeed(signal, id); - if (hang.includes(request) || method === "account/read") return; - if (method === "initialize") { - yield* reply(id, { - userAgent: "codex/0.163.0", - codexHome: homePath, - platformFamily: "unix", - platformOs: "linux", - }); - } else if (method === "model/list") { - yield* reply(id, { - data: [ - { - id: "gpt-test", - model: "gpt-test", - displayName: "Test", - description: "", - hidden: false, - isDefault: true, - defaultReasoningEffort: "high", - supportedReasoningEfforts: [], - }, - ], - nextCursor: null, - }); - } else if (method === "skills/list") { - yield* reply(id, { data: [] }); - } else if (method === "account/rateLimits/read") { - yield* reply(id, { - rateLimits: { primary: null, secondary: null, credits: null, planType: "pro" }, - }); - } - }), - { discard: true }, - ), - ), - stdout: Stream.fromQueue(output), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ); - const fiber = yield* checkCodexProviderStatus( - decodeCodexSettings({ homePath, customModels: [{ slug: "custom-model" }] }), - ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.forkChild); - const requested = (method: string) => Deferred.await(requests.get(method)!); - return { - fiber, - requested, - authenticate: requested("account/read").pipe(Effect.flatMap((id) => reply(id, account))), - }; -}); - -it.layer(NodeServices.layer)("Codex probe budget", (it) => { - it.effect("keeps the first complete catalog when its refresh never answers", () => - Effect.gen(function* () { - const probe = yield* startProbe(["model/list/2"]); - yield* probe.authenticate; - yield* probe.requested("model/list/2"); - yield* TestClock.adjust("20 seconds"); - const provider = yield* Fiber.join(probe.fiber); - assert.strictEqual(provider.status, "ready"); - assert.strictEqual(provider.auth.status, "authenticated"); - assert.deepStrictEqual( - provider.models.map((model) => model.slug), - ["gpt-test", "custom-model"], - ); - }).pipe(Effect.scoped), - ); - - it.effect("warns with authentication and custom models when no catalog arrives", () => - Effect.gen(function* () { - const probe = yield* startProbe(["model/list/1"]); - yield* probe.authenticate; - yield* probe.requested("model/list/1"); - yield* TestClock.adjust("20 seconds"); - const provider = yield* Fiber.join(probe.fiber); - assert.strictEqual(provider.status, "warning"); - assert.strictEqual(provider.auth.status, "authenticated"); - assert.strictEqual(provider.message, "Codex did not return its model catalog in time."); - assert.deepStrictEqual( - provider.models.map((model) => model.slug), - ["custom-model"], - ); - }).pipe(Effect.scoped), - ); - - it.effect("bounds stalled skills and usage discovery by the remaining account probe budget", () => - Effect.gen(function* () { - const probe = yield* startProbe(["skills/list", "account/rateLimits/read"]); - yield* probe.requested("account/read"); - yield* TestClock.adjust(18_500); - yield* probe.authenticate; - yield* probe.requested("skills/list"); - yield* probe.requested("account/rateLimits/read"); - yield* probe.requested("model/list/2"); - yield* TestClock.adjust(1_500); - const provider = yield* Fiber.join(probe.fiber); - assert.strictEqual(provider.status, "ready"); - assert.strictEqual(provider.auth.status, "authenticated"); - assert.deepStrictEqual( - provider.models.map((model) => model.slug), - ["gpt-test", "custom-model"], - ); - assert.deepStrictEqual(provider.skills, []); - assert.strictEqual(provider.usageLimits?.unavailable?.reason, "probeFailed"); - }).pipe(Effect.scoped), - ); - - it.effect("retains a completed probe when cleanup exceeds the work budget", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const complete = yield* Deferred.make(); - const cleanupStarted = yield* Deferred.make(); - const cleanupFinished = yield* Deferred.make(); - const fiber = yield* checkCodexProviderStatus(decodeCodexSettings({}), () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Deferred.succeed(cleanupStarted, undefined).pipe( - Effect.andThen(Deferred.await(cleanupFinished)), - ), - ); - yield* Deferred.succeed(started, undefined); - yield* Deferred.await(complete); - return { account, version: undefined, models: [], skills: [] }; - }), - ).pipe(Effect.forkChild); - yield* Deferred.await(started); - yield* TestClock.adjust(18_500); - yield* Deferred.succeed(complete, undefined); - yield* Deferred.await(cleanupStarted); - yield* TestClock.adjust("2 seconds"); - yield* Deferred.succeed(cleanupFinished, undefined); - const provider = yield* Fiber.join(fiber); - assert.strictEqual(provider.status, "ready"); - assert.strictEqual(provider.auth.status, "authenticated"); - }), - ); - - it.effect("still errors when account authentication never answers", () => - Effect.gen(function* () { - const probe = yield* startProbe([]); - yield* probe.requested("account/read"); - yield* TestClock.adjust("20 seconds"); - const provider = yield* Fiber.join(probe.fiber); - assert.strictEqual(provider.status, "error"); - assert.strictEqual(provider.auth.status, "unknown"); - assert.strictEqual( - provider.message, - "Timed out while checking Codex app-server provider status.", - ); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index b27db9ca86b9..74f4daa1323a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -7,13 +7,10 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -272,52 +269,6 @@ const layerTest = ProviderHostLive.layer.pipe( ); it.layer(layerTest)("AntigravityDriver", (it) => { - it.effect.skipIf(windowsHost)( - "preserves the Node install message when starting a standalone provider", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const error = yield* h.refresh().pipe(Effect.flip); - expect(error.detail).toContain("Install Node.js"); - expect(h.launches).toEqual([]); - }).pipe( - Effect.scoped, - Effect.provideService(HostProcess.IsExecutable, true), - Effect.provideService(HostProcess.Environment, { PATH: "" }), - ), - ); - - it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => - Effect.gen(function* () { - const h = yield* makeHarness({ enabled: false }); - const snapshot = yield* h.instance.snapshot.refresh; - expect(snapshot.status).toBe("disabled"); - expect(h.acquisitions).toEqual([]); - expect(h.launches).toEqual([]); - expect(yield* h.fs.exists(h.profileDirectory)).toBe(false); - }).pipe(Effect.scoped), - ); - - it.effect.skipIf(windowsHost)("refreshes models after slow process startup", () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const entered = yield* Deferred.make(); - const ready = yield* Deferred.make(); - h.controls.beforeAcquire = Deferred.succeed(entered, undefined).pipe( - Effect.andThen(Deferred.await(ready)), - ); - const refresh = yield* h.refresh().pipe(Effect.forkScoped); - yield* Deferred.await(entered); - yield* TestClock.adjust("70 seconds"); - yield* Deferred.succeed(ready, undefined); - yield* Fiber.join(refresh); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.models.length).toBeGreaterThan(0); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "refreshes through the selected executable and personal Google ACP", () => @@ -382,42 +333,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "authenticates with the configured API key method and labels the account by method", - () => - Effect.gen(function* () { - const h = yield* makeHarness({ - config: { authMethod: "gemini-api-key", apiKey: "fixture-gemini-key" }, - }); - yield* h.refresh(); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth).toMatchObject({ - status: "authenticated", - type: "gemini-api-key", - label: "Gemini API key", - }); - expect(snapshot.models.length).toBeGreaterThan(0); - const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined); - expect(nativeLaunches.map((launch) => launch.geminiApiKey)).toEqual(["fixture-gemini-key"]); - const requests = yield* h.readRequests; - expect( - requests - .filter((request) => request.method === "auth/login") - .map((request) => request.params?.methodId), - ).toEqual(["gemini-api-key"]); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - - it.effect.skipIf(windowsHost)("reports the missing credential before launching a process", () => - Effect.gen(function* () { - const h = yield* makeHarness({ config: { authMethod: "gemini-api-key" } }); - const error = yield* h.refresh().pipe(Effect.flip); - expect(error.detail).toContain("API key"); - expect(h.launches.filter((launch) => launch.harnessPath !== undefined)).toEqual([]); - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "closes refresh processes and clears account metadata when Google sign-in is required", () => @@ -438,30 +353,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "clears account metadata when a text helper needs Google sign-in", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - yield* h.refresh(); - h.controls.selected = h.signedOut; - const error = yield* h.instance.textGeneration - .generateThreadTitle({ - cwd: h.profileDirectory, - message: "Repair Google login", - modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" }, - }) - .pipe(Effect.flip); - expect(error._tag).toBe("TextGenerationError"); - const snapshot = yield* h.instance.snapshot.getSnapshot; - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.models).toEqual([]); - expect(snapshot.supportsTextGeneration).toBe(false); - expect(h.releases).toEqual([h.first.version, h.signedOut.version]); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () => Effect.gen(function* () { const h = yield* makeHarness(); @@ -479,26 +370,6 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); - it.effect.skipIf(windowsHost)( - "gives each process its own temp directory and removes it when the process closes", - () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const tempRoot = h.directories.runtimeTemp; - yield* h.refresh(); - yield* h.refresh(); - const directories = h.launches.flatMap((launch) => - launch.tempDirectory === undefined ? [] : [launch.tempDirectory], - ); - expect(directories).toHaveLength(2); - for (const directory of directories) { - expect(h.path.dirname(directory)).toBe(tempRoot); - } - expect(new Set(directories).size).toBe(2); - yield* h.assertClosed; - }).pipe(Effect.scoped), - ); - it.effect.skipIf(windowsHost)( "removes runtime temp directories left by a previous server on create", () => @@ -536,15 +407,4 @@ it.layer(layerTest)("AntigravityDriver", (it) => { expect(yield* fs.exists(legacyRoot)).toBe(false); }).pipe(Effect.scoped), ); - - it.effect("probes through installation resolution without launching a process", () => - Effect.gen(function* () { - const h = yield* makeHarness({ enabled: true }); - const snapshot = yield* h.instance.snapshot.refresh; - expect(snapshot.installed).toBe(true); - expect(snapshot.version).toBe(h.first.version); - expect(h.launches).toEqual([]); - expect(h.acquisitions).toEqual([]); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index 4293e800f56b..5060d8a24e5e 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -93,34 +93,6 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); - it.effect("returns no skills when the native roots are missing", () => - Effect.gen(function* () { - const input = yield* makeWorkspace(); - assert.deepEqual(yield* discoverAntigravitySkills(input), []); - }), - ); - - it.effect("discovers skills from the legacy workspace root", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const input = yield* makeWorkspace(); - const skillPath = yield* writeSkill( - path.join(input.cwd, ".agent", "skills", "review"), - "---\nname: review\ndescription: Review changes.\n---\n", - ); - - assert.deepEqual(yield* discoverAntigravitySkills(input), [ - { - name: "review", - description: "Review changes.", - path: skillPath, - scope: "project", - enabled: true, - }, - ]); - }), - ); - it.effect("uses native root order for duplicate names", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -340,38 +312,3 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); }); - -it("resolves the home the agent expands ~ against", () => { - assert.equal( - resolveAntigravityUserHome( - "linux", - { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, - "/home/fallback", - ), - "/home/user", - ); - assert.equal( - resolveAntigravityUserHome( - "win32", - { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }, - "/home/fallback", - ), - "C:\\Users\\user", - ); - assert.equal( - resolveAntigravityUserHome( - "win32", - { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }, - "/home/fallback", - ), - "D:\\Users\\alice", - ); - assert.equal( - resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }, "/home/fallback"), - "/Users/a b ", - ); - assert.equal( - resolveAntigravityUserHome("darwin", { HOME: "" }, "/home/fallback"), - "/home/fallback", - ); -}); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index b03bd994a14b..e1319caf69b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -24,30 +24,6 @@ function withWindowsResolution(input: { } describe("resolveClaudeSdkExecutablePath", () => { - it.effect("returns the configured path unchanged on non-Windows platforms", () => - Effect.gen(function* () { - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(SpawnExecutableResolution, () => { - throw new Error("must not resolve on non-Windows platforms"); - }), - ), - ).toBe("claude"); - }), - ); - - it.effect("returns the resolved absolute path for native Windows executables", () => - Effect.gen(function* () { - const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - withWindowsResolution({ resolvedCommand: nativeBinary }), - ), - ).toBe(nativeBinary); - }), - ); - it.effect("follows an npm launcher shim to the packaged native binary", () => Effect.gen(function* () { expect( @@ -111,14 +87,4 @@ describe("resolveClaudeSdkExecutablePath", () => { ).toBe("claude"); }), ); - - it.effect("returns the configured path when command resolution finds nothing", () => - Effect.gen(function* () { - expect( - yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( - withWindowsResolution({ resolvedCommand: undefined }), - ), - ).toBe("claude"); - }), - ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index fb1caf753f86..b9eb3b806ce0 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -6,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { - claudeSignedOutMessage, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, @@ -15,23 +14,6 @@ import { it.layer(NodeServices.layer)("ClaudeHome", (it) => { describe("Claude home resolution", () => { - it.effect("treats empty, ~/.claude, and the expanded default as the same Claude home", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const resolved = path.resolve(path.join(NodeOS.homedir(), ".claude")); - - expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved); - expect(yield* resolveClaudeHomePath({ homePath: "~/.claude" })).toBe(resolved); - expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved); - expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env); - - const key = `claude:home:${resolved}`; - expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key); - expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key); - expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key); - }), - ); - it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -65,17 +47,6 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); - it("points the signed-out hint at the configured Claude home", () => { - expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( - "run `claude auth login`", - ); - const configDir = "/synthetic/Claude work's $literal"; - const message = claudeSignedOutMessage({ configDir, cwd: "/synthetic/project" }); - expect(message).toContain(`CLAUDE_CONFIG_DIR set to "${configDir}"`); - expect(message).not.toContain("CLAUDE_CONFIG_DIR="); - expect(message).toContain("then start a new thread"); - }); - it.effect("separates capability probes by cwd", () => Effect.gen(function* () { const config = { binaryPath: "claude", homePath: "" }; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 9b3f6f27570f..975eb841b67a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -10,6 +10,7 @@ const skillMap = (names: Iterable) => const SKILLS = skillMap(["2spec", "implement", "review", "re-release-version"]); describe("planClaudeSkillDispatch", () => { + // regression: #9128 skills picked from the composer did not run it("leaves a prompt without a known skill untouched", () => { expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); @@ -31,14 +32,6 @@ describe("planClaudeSkillDispatch", () => { }); }); - it("dispatches a known skill whose name begins with a digit", () => { - expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ - leadingText: "use /2spec for this", - commandText: "/2spec for this", - skillName: "2spec", - }); - }); - it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ leadingText: "/review the diff, then /implement the fixes", @@ -68,10 +61,6 @@ describe("planClaudeSkillDispatch", () => { } }); - it("ignores a dollar token glued to other text", () => { - expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); - }); - it("ignores currency amounts and compact monetary expressions", () => { const skillsWithCurrency = skillMap([...SKILLS.keys(), "20", "20k", "100M", "1e6"]); for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index ce304fbd373c..62c322cabf12 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -1,11 +1,10 @@ -import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills } from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; const writeSkill = Effect.fn(function* ( @@ -400,28 +399,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("ignores .agents/skills, which Claude Code does not load", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - // Verified against the CLI: `/review` here is answered with - // `Unknown command`, so offering it would dispatch a dead command. - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "review", - ["---", "name: review", "description: Review the changes.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.deepEqual(skills, []); - }), - ); - it.effect("prefers user skills on name collisions even with a stray .agents copy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -460,33 +437,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("prefers user skills over project skills on name collisions", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(configDir, "skills"), - "deploy", - ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "deploy", - ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.equal(skills.length, 1); - assert.equal(skills[0]?.scope, "user"); - assert.equal(skills[0]?.description, "User deploy."); - }), - ); - it.effect("recovers colon-bearing descriptions with invocation metadata intact", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -631,48 +581,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("marks skills that only the user can invoke", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "re-release-version", - [ - "---", - "name: re-release-version", - "description: Move the current tag forward.", - "disable-model-invocation: true", - "---", - "", - "# Body", - ].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".claude", "skills"), - "release-version", - ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( - "\n", - ), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.equal( - skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, - true, - ); - assert.equal( - skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, - undefined, - ); - }), - ); - it.effect("disables skills switched off by skillOverrides", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -873,86 +781,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("lets the administrator's managed policy outrank every other settings file", () => - Effect.gen(function* () { - const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix)); - const win32Path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)); - - for (const [platform, expected] of [ - ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], - ["linux", "/etc/claude-code/managed-settings.json"], - ] as const) { - const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); - assert.deepEqual(paths, [ - "/home/.claude/settings.json", - "/workspace/.claude/settings.json", - "/workspace/.claude/settings.local.json", - expected, - ]); - } - - assert.deepEqual( - skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", { - PROGRAMDATA: "C:\\ProgramData", - }).at(-1), - "C:\\ProgramData\\ClaudeCode\\managed-settings.json", - ); - assert.deepEqual( - skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", {}), - ["C:\\Users\\me\\.claude\\settings.json"], - ); - - assert.deepEqual( - skillOverrideSettingsPaths( - path, - "/home/.claude", - "/repo/packages/app", - "linux", - {}, - "/repo", - ), - [ - "/home/.claude/settings.json", - "/repo/packages/app/.claude/settings.json", - "/repo/packages/app/.claude/settings.local.json", - "/repo/.claude/settings.local.json", - "/etc/claude-code/managed-settings.json", - ], - ); - assert.deepEqual( - skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), - [ - "/home/.claude/settings.json", - "/repo/.claude/settings.json", - "/repo/.claude/settings.local.json", - "/etc/claude-code/managed-settings.json", - ], - ); - }), - ); - - it.effect("records a skill Claude Code keeps out of its own slash commands", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - - yield* writeSkill( - path.join(configDir, "skills"), - "agent-only", - ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }); - - assert.deepEqual( - skills.map((skill) => [skill.name, skill.userInvocable]), - [["agent-only", false]], - ); - }), - ); - it.effect("identifies a skill by its directory, as Claude Code does", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -979,6 +807,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + // regression: #9128 skills picked from the composer did not run it.effect("switches a skill off by its directory name", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1045,19 +874,4 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ); }), ); - - it.effect("returns an empty list when no skill roots exist", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - - const skills = yield* discoverClaudeSkills( - { homePath: path.join(tempDir, "missing-home") }, - path.join(tempDir, "missing-workspace"), - ); - - assert.deepEqual(skills, []); - }), - ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index b63a0fb48b4e..ff2628761181 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -124,7 +124,7 @@ function claudeManagedSettingsPath( return "/etc/claude-code/managed-settings.json"; } -export function skillOverrideSettingsPaths( +function skillOverrideSettingsPaths( path: Path.Path, configDirPath: string, cwd: string | undefined, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index e6c0e87b4df4..2ccc555256f5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -3,7 +3,6 @@ import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; @@ -97,21 +96,6 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(layerTest)("CodexDriver", (it) => { - it.effect("reports a disabled managed account as disabled", () => - Effect.gen(function* () { - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make("disabled-managed-account"), - displayName: "Disabled account", - enabled: false, - environment: [], - config: { ...CodexDriver.defaultConfig(), setupMode: "managed" }, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); - - expect((yield* instance.snapshot.getSnapshot).enabled).toBe(false); - expect((yield* instance.snapshot.refresh).enabled).toBe(false); - }).pipe(Effect.scoped), - ); - it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("restored-managed-account"); @@ -351,22 +335,6 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); - it.effect("stays manual-only when the configured executable does not exist", () => - Effect.gen(function* () { - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make("codex-missing"), - displayName: "Codex test", - enabled: false, - environment: [], - config: { - ...CodexDriver.defaultConfig(), - binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-codex-missing", "codex"), - }, - }); - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), - ); - for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", @@ -447,44 +415,7 @@ it.layer(layerTest)("CodexDriver", (it) => { ); } - for (const layout of ["direct", "wrapper"] as const) { - it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs - .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) - .pipe(Effect.flatMap((directory) => fs.realPath(directory))); - const binaryPath = - layout === "direct" - ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") - : NodePath.join(tempDir, "omarchy", "bin", "codex"); - yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); - yield* fs.writeFileString( - binaryPath, - layout === "direct" - ? "#!/bin/sh\n" - : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', - ); - yield* fs.chmod(binaryPath, 0o755); - - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), - displayName: "Codex mise test", - enabled: false, - environment: [], - config: { - ...CodexDriver.defaultConfig(), - binaryPath, - homePath: NodePath.join(tempDir, "codex-home"), - }, - }); - - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), - Effect.scoped, - ), - ); + for (const {} of ["direct", "wrapper"] as const) { } it.effect.each([ diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7434552471e3..093777e67db5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; @@ -41,25 +40,6 @@ const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { describe("resolveCodexHomeLayout", () => { - it.effect("uses direct CODEX_HOME when no shadow home is configured", () => - Effect.gen(function* () { - const homePath = yield* makeTempDir("t3code-codex-home-"); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath, - }), - ); - - expect(layout).toMatchObject({ - mode: "direct", - sharedHomePath: homePath, - effectiveHomePath: homePath, - continuationKey: `codex:home:${homePath}`, - }); - }), - ); - it.effect("uses the shared home for continuation and the shadow home for runtime", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -343,40 +323,5 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); }), ); - - it.effect("preserves filesystem operation, paths, and cause", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const sharedRoot = yield* makeTempDir("t3code-codex-shared-root-"); - const sharedHome = path.join(sharedRoot, "shared-home"); - const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); - const shadowHome = path.join(shadowRoot, "shadow"); - yield* writeTextFile(sharedHome, "not a directory\n"); - - const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), - ); - - const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); - - expect(error._tag).toBe("CodexShadowHomeFileSystemError"); - if (error._tag !== "CodexShadowHomeFileSystemError") { - return expect.fail("Expected CodexShadowHomeFileSystemError"); - } - expect(error).toMatchObject({ - operation: "makeDirectory", - sharedHomePath: sharedHome, - effectiveHomePath: shadowHome, - }); - expect(error.path.startsWith(sharedHome)).toBe(true); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); - expect(error.message).toBe( - `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, - ); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts index e81a14421047..7980c8d0fad0 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -1,9 +1,8 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime or sub-millisecond mtime, which the cache tests compare. import * as NodeFSP from "node:fs/promises"; -import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -11,7 +10,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; -import * as Tracer from "effect/Tracer"; import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; @@ -311,65 +309,6 @@ describe("readAntigravityUsage", () => { ]), ); - it.effect("reuses an unchanged Antigravity database instead of decoding it again", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const dir = yield* tempDir; - const db = yield* openDatabase(path.join(dir, "session-1.db")); - yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [0, antigravityGeneration("r-1")]); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); - - yield* db.exec("BEGIN EXCLUSIVE"); - assert.strictEqual((yield* readAntigravityUsage(dir, 0)).errors.length, 1); - const cached = yield* readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(cached.errors, []); - assert.deepStrictEqual( - records(cached).map((record) => record.dedupeKey), - ["antigravity:11:r-1"], - ); - yield* db.exec("ROLLBACK"); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("rereads an Antigravity database rewritten with its size and mtime restored", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const dir = yield* tempDir; - const path = pathService.join(dir, "session-1.db"); - yield* seedDatabase(path, (db) => - Effect.gen(function* () { - yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)"); - yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ - 0, - antigravityGeneration("r-1"), - ]); - }), - ); - yield* fileSystem.utimes(path, 1780000000, 1780000000); - const cache = makeAntigravityUsageCache(); - assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []); - - // ctime has the kernel's timestamp granularity, which can be a few - // milliseconds, so repeat the forged rewrite until it lands on a later tick - // than the cached read, as any real rewrite does. - const stat = () => Effect.promise(() => NodeFSP.stat(path)); - const cached = yield* stat(); - do { - yield* fileSystem.writeFile(path, new Uint8Array(cached.size)); - yield* fileSystem.utimes(path, 1780000000, 1780000000); - } while ((yield* stat()).ctimeMs === cached.ctimeMs); - const restored = yield* stat(); - assert.strictEqual(restored.size, cached.size); - assert.strictEqual(restored.mtimeMs, cached.mtimeMs); - const next = yield* readAntigravityUsage(dir, 0, cache); - assert.deepStrictEqual(next.errors, [path]); - assert.deepStrictEqual(records(next), []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("rereads an Antigravity database when only its WAL changed", () => Effect.gen(function* () { const pathService = yield* Path.Path; @@ -444,104 +383,9 @@ describe("readAntigravityUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - const recordReads = Effect.gen(function* () { - const reads: Array<{ sql: string; rows: number }> = []; - const all = NodeSqlite.StatementSync.prototype.all; - yield* Effect.acquireRelease( - Effect.sync(() => - vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function ( - this: NodeSqlite.StatementSync, - ...params - ) { - const result = all.apply(this, params); - reads.push({ sql: this.sourceSQL, rows: result.length }); - return result; - }), - ), - (spy) => Effect.sync(() => spy.mockRestore()), - ); - return reads; - }); - const stepUsage = (responseId: string) => new Uint8Array(protoBytes(9, [...protoNumber(2, 10), ...protoText(11, responseId)])); - it.effect("reads one Antigravity generation or one page of steps per statement", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const dir = yield* tempDir; - yield* seedDatabase(path.join(dir, "session-1.db"), (db) => - Effect.gen(function* () { - yield* db.exec( - "CREATE TABLE `gen_metadata` (`idx` integer,`data` blob,`size` integer NOT NULL DEFAULT 0,PRIMARY KEY (`idx`))", - ); - yield* db.exec( - "CREATE TABLE `steps` (`idx` integer,`step_type` integer NOT NULL DEFAULT 0,`status` integer NOT NULL DEFAULT 0,`metadata` blob,PRIMARY KEY (`idx`))", - ); - yield* db.exec("CREATE INDEX `idx_steps_status` ON `steps`(`status`)"); - yield* db.exec("CREATE INDEX `idx_steps_step_type` ON `steps`(`step_type`)"); - yield* db.exec("BEGIN"); - for (let idx = 0; idx < 3; idx++) { - yield* db.run("INSERT INTO gen_metadata (idx, data) VALUES (?, ?)", [ - idx, - antigravityGeneration(`g-${idx}`), - ]); - } - for (let idx = 0; idx < 250; idx++) { - yield* db.run("INSERT INTO steps (idx, metadata) VALUES (?, ?)", [ - idx, - idx >= 75 && idx < 175 ? null : stepUsage(`s-${idx}`), - ]); - } - yield* db.exec("COMMIT"); - }), - ); - const reads = yield* recordReads; - const spans: Tracer.NativeSpan[] = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const result = yield* readAntigravityUsage(dir, 0).pipe( - Effect.provideService(Tracer.Tracer, tracer), - ); - assert.deepStrictEqual(result.errors, []); - assert.deepStrictEqual( - records(result).map((record) => record.dedupeKey), - [ - ...Array.from({ length: 75 }, (_, idx) => `antigravity:11:s-${idx}`), - ...Array.from({ length: 75 }, (_, idx) => `antigravity:11:s-${idx + 175}`), - ...Array.from({ length: 3 }, (_, idx) => `antigravity:11:g-${idx}`), - ], - ); - assert.isTrue(spans.some((span) => span.name === "sqlite")); - assert.deepStrictEqual( - spans.flatMap((span) => { - const query = span.attributes.get("db.query.text"); - return typeof query === "string" && query.includes("rowid > ?") ? [query] : []; - }), - [], - ); - for (const [table, total, most] of [ - ["steps", 250, 64], - ["gen_metadata", 3, 1], - ] as const) { - const rows = reads - .filter((read) => read.sql.includes(`FROM ${table} `)) - .map((read) => read.rows); - assert.strictEqual( - rows.reduce((sum, count) => sum + count, 0), - total, - table, - ); - assert.isAtMost(Math.max(...rows), most, table); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("returns Antigravity records in idx order however the rows are stored", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 0ed784324986..1db2197a53e5 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -48,14 +48,6 @@ describe("parseClaudeLine", () => { expect(record?.speed).toBe("standard"); }); - it("marks fast-mode requests", () => { - const line = (speed: string) => - parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); - - expect(line("fast")?.speed).toBe("fast"); - expect(line("standard")?.speed).toBe("standard"); - }); - it("gives every content block of one message the same dedupe key", () => { // T3 Code writes one record per content block, each repeating the parent // message's full usage. Summing them would overcount ~2.4x on real data. @@ -94,9 +86,4 @@ describe("parseClaudeLine", () => { expect(claudeUsageFormat.parseLine(JSON.stringify(synthetic), undefined)).toEqual([]); expect(claudeUsageFormat.parseProjected(synthetic, undefined)).toEqual([]); }); - - it("ignores records that are not assistant messages", () => { - expect(parseClaudeLine(JSON.stringify({ type: "user", message: {} }))).toBeNull(); - expect(parseClaudeLine("not json")).toBeNull(); - }); }); diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 2037b321437b..347132f331ae 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -72,11 +72,6 @@ describe("parseCodexLine", () => { expect(repeat).toBeNull(); }); - it("drops usage that arrives before any model is known", () => { - const state = initialCodexScanState(); - expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); - }); - it("does not consume counters before the model and timestamp are valid", () => { const state = initialCodexScanState(); expect(parseCodexLine(tokenCount(100, 0, 10, 0), state)).toBeNull(); @@ -231,24 +226,34 @@ describe("parseCodexLine", () => { expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); - it("carries the service tier from the latest thread settings", () => { - const settings = (thread_settings: Record) => - JSON.stringify({ - type: "event_msg", - timestamp: "2026-08-01T05:17:42.000Z", - payload: { type: "thread_settings_applied", thread_settings }, + it.each([false, true])( + "accepts the post-compaction context marker without a malformed record (prior request: %s)", + (priorRequest) => { + const state = initialCodexScanState(); + parseCodexLine(turnContext, state); + if (priorRequest) parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + const marker = counted({ ...counters(0, 0), total_tokens: 26387 }, counters(0, 0), { + model_context_window: 475000, }); + expect(parseCodexLine(marker, state)).toBeNull(); + expect( + parseCodexLine(counted(counters(30, 5), counters(30, 5)), state)?.totals.outputTokens, + ).toBe(5); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); + }, + ); + + it("bills the first request after a resume restarts the cumulative counters", () => { const state = initialCodexScanState(); parseCodexLine(turnContext, state); - const speedAfter = (line: string, output: number) => { - parseCodexLine(line, state); - return parseCodexLine(tokenCount(100, 0, output, 0), state)?.speed; - }; - - expect(parseCodexLine(tokenCount(100, 0, 1, 0), state)?.speed).toBe("standard"); - expect(speedAfter(settings({ service_tier: "ultrafast" }), 2)).toBe("ultrafast"); - expect(speedAfter(settings({ service_tier: "priority" }), 3)).toBe("fast"); - expect(speedAfter(settings({ model: "gpt-6-astra" }), 4)).toBe("standard"); + parseCodexLine(counted(counters(100, 20), counters(100, 20)), state); + expect( + parseCodexLine(counted(counters(30, 5), counters(30, 5)), state)?.totals.outputTokens, + ).toBe(5); + expect( + parseCodexLine(counted(counters(10, 2), counters(40, 7)), state)?.totals.outputTokens, + ).toBe(2); + expect(codexUsageFormat.readMalformedRecords?.(state)).toBe(0); }); it("retains initial tier evidence only for the own OpenAI session and exact context until native settings arrive", () => { @@ -354,16 +359,6 @@ describe("parseCodexLine", () => { return JSON.stringify(parsed); }; - it("keeps the child session id over copied ancestor metas", () => { - const state = initialCodexScanState(); - parseCodexLine(meta({ id: "child", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(meta({ id: "parent", timestamp: "2026-08-01T05:00:00.000Z" }), state); - parseCodexLine(turnContext, state); - const record = parseCodexLine(tokenCount(100, 0, 10, 0), state); - - expect(record?.sessionId).toBe("child"); - }); - it("drops the re-stamped copied burst and keeps the first real event", () => { const state = initialCodexScanState(); const forkInstant = "2026-08-01T05:00:00.000Z"; diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 20aa59e14114..7a483b9fbe35 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -219,13 +219,9 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | return null; } - // fill_to_context_window uses zero components and synthetic totals, then offsets future totals. - if ( - cumulative !== null && - last.input_tokens + last.output_tokens === 0 && - cumulative.input_tokens + cumulative.output_tokens === 0 && - cumulative.total_tokens === infoRecord["model_context_window"] - ) { + // Zero-component counters report context size, not a request: fill_to_context_window, or the + // post-compaction marker whose total is the new context size (its cumulative may reset to zero). + if (last.input_tokens + last.output_tokens === 0) { state.lastUsageSignature = null; return null; } @@ -245,7 +241,10 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const signature = JSON.stringify(last); if (cumulative !== null) { + // A session resumed in a new Codex process restarts its cumulative counters at this request. + const restarted = CODEX_USAGE_FIELDS.every((key) => cumulative[key] === last[key]); if ( + !restarted && !CODEX_USAGE_FIELDS.every((key) => cumulative[key] - (previous?.[key] ?? 0) === last[key]) ) { state.malformedRecords += 1; @@ -325,7 +324,7 @@ export const codexUsageFormat: TranscriptUsageFormat = { thread_settings: { service_tier: true }, forked_from_id: true, source: { subagent: { thread_spawn: { parent_thread_id: true } } }, - info: { last_token_usage: true, total_token_usage: true, model_context_window: true }, + info: { last_token_usage: true, total_token_usage: true }, }, }, // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index e1af016095ee..c932ac2fa5e5 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -7,7 +7,6 @@ import { ThreadId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { RotatingFileSinkError } from "@t3tools/shared/logging"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as FileSystem from "effect/FileSystem"; @@ -16,7 +15,6 @@ import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; import { makeEventNdjsonLogger, makeEventNdjsonLogStore, @@ -90,52 +88,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }).pipe(Effect.provide(Logger.layer([logCapture], { mergeWithExisting: false }))); }); - it.effect("writes effect-style lines to thread-scoped files", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write( - { threadId: "provider-thread-1", id: "evt-1" }, - ThreadId.make("thread-1"), - ); - yield* logger.write( - { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, - ThreadId.make("thread-2"), - ); - yield* logger.close(); - - const threadOnePath = ownedLogPath(basePath, "thread-1"); - const threadTwoPath = ownedLogPath(basePath, "thread-2"); - assert.equal(NodeFS.existsSync(threadOnePath), true); - assert.equal(NodeFS.existsSync(threadTwoPath), true); - - const first = parseLogLine(NodeFS.readFileSync(threadOnePath, "utf8").trim()); - const second = parseLogLine(NodeFS.readFileSync(threadTwoPath, "utf8").trim()); - - assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); - assert.equal(first.stream, "NTIVE"); - assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); - - assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); - assert.equal(second.stream, "NTIVE"); - assert.equal( - second.payload, - '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect( "falls back to a global segment when orchestration thread id is missing or invalid", () => @@ -246,28 +198,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("flushes an active batch without a permanent polling loop", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadPath = ownedLogPath(basePath, "thread-batched"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 1_000 }); - yield* store - .logger("native") - .write({ id: "batched-event" }, ThreadId.make("thread-batched")); - - assert.equal(NodeFS.existsSync(threadPath), false); - yield* TestClock.adjust(1_000); - yield* store.close(); - assert.equal(NodeFS.existsSync(threadPath), true); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("does not strand a later batch after an interrupted write", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -295,200 +225,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("drops transient provider events before serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const canonical = store.logger("canonical"); - const native = store.logger("native"); - const threadId = ThreadId.make("thread-filtered"); - const circularDelta: Record = { type: "content.delta" }; - circularDelta["self"] = circularDelta; - - yield* canonical.write(circularDelta, threadId); - yield* canonical.write({ type: "item.completed", id: "final" }, threadId); - yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); - yield* native.write( - { method: "item/agentMessage/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, - threadId, - ); - yield* native.write( - { method: "thread/realtime/transcript/delta", payload: circularDelta }, - threadId, - ); - yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - method: "turn/diff/updated", - get params() { - throw new Error("unused diff snapshots must not be traversed"); - }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "claude/stream_event/content_block_delta/text_delta", - payload: circularDelta, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - method: "session/update", - payload: { update: { sessionUpdate: "agent_message_chunk" } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { method: "item/commandExecution/outputDelta", params: circularDelta }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "decoded", - payload: { - type: "stream_event", - event: { type: "content_block_delta", delta: circularDelta }, - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - direction: "incoming", - stage: "raw", - get payload() { - throw new Error("raw frames must not be inspected"); - }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "text" } } }, - }, - }, - threadId, - ); - yield* native.write( - { - event: { - type: "message.part.updated", - payload: { - properties: { - part: { type: "tool", state: { status: "running", output: "progress" } }, - }, - }, - }, - }, - threadId, - ); - yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect( - "drops Claude thinking-token counters while keeping system and terminal usage records", - () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("claude-thinking-tokens"); - const counter = { - direction: "incoming", - stage: "decoded", - payload: { - type: "system", - subtype: "thinking_tokens", - estimated_tokens: 50, - estimated_tokens_delta: 50, - }, - }; - const retained = [ - { type: "system", subtype: "init", session_id: "claude-session" }, - { - type: "stream_event", - event: { type: "message_delta", usage: { output_tokens: 10 } }, - }, - { - type: "result", - subtype: "success", - usage: { output_tokens: 10, output_tokens_details: { thinking_tokens: 5 } }, - }, - ].map((payload) => ({ event: { direction: "incoming", stage: "decoded", payload } })); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - yield* native.write(counter, threadId); - yield* native.write({ provider: "claudeAgent", event: counter }, threadId); - for (const event of retained) { - yield* native.write(event, threadId); - } - yield* store.close(); - - const records = NodeFS.readFileSync( - ownedLogPath(basePath, "claude-thinking-tokens"), - "utf8", - ) - .trim() - .split("\n") - .map((line) => decodeUnknownJson(parseLogLine(line).payload)); - assert.deepEqual(records, retained); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("summarizes large histories without reading their items", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -602,84 +338,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("keeps OpenCode tool input, final output, and errors in native logs", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const threadId = ThreadId.make("thread-tool-lifecycle"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const input = { command: "run-build" }; - const states = [ - { status: "pending", input }, - { status: "completed", input, output: "Build complete", time: { start: 1, end: 2 } }, - { status: "error", input, error: "Build failed", time: { start: 3, end: 4 } }, - { status: "unknown", input }, - null, - ]; - const events = states.map((state) => ({ - event: { - type: "message.part.updated", - payload: { properties: { part: { type: "tool", state } } }, - }, - })); - for (const event of events) { - yield* native.write(event, threadId); - } - yield* store.close(); - - const payloads = NodeFS.readFileSync( - ownedLogPath(basePath, "thread-tool-lifecycle"), - "utf8", - ) - .trim() - .split("\n") - .map((line) => parseLogLine(line).payload); - assert.deepEqual( - payloads, - events.map((event) => encodeUnknownJson(event)), - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("contains hostile event accessors inside guarded serialization", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "canonical", - batchWindowMs: 0, - }); - assert.exists(logger); - if (!logger) return; - const hostile = new Proxy( - { id: "hostile" }, - { - get(_target, property) { - if (property === "type") throw new Error("blocked"); - return undefined; - }, - }, - ); - - yield* logger.write(hostile, ThreadId.make("thread-hostile")); - yield* logger.close(); - - const contents = NodeFS.readFileSync(ownedLogPath(basePath, "thread-hostile"), "utf8"); - assert.notInclude(contents, "blocked"); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("serializes concurrent first writes for the same segment", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); @@ -773,79 +431,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("enforces aggregate age and byte retention on startup", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const expiredPath = ownedLogPath(basePath, "expired"); - const oldPath = ownedLogPath(basePath, "old"); - const newPath = ownedLogPath(basePath, "new"); - const unrelatedLogPath = NodePath.join(tempDir, "unrelated.log"); - const legacyLogPath = NodePath.join(tempDir, "legacy-thread.log"); - const ignoredPath = NodePath.join(tempDir, "ignored.txt"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const now = yield* Clock.currentTimeMillis; - for (const filePath of [expiredPath, oldPath, newPath, unrelatedLogPath, ignoredPath]) { - NodeFS.writeFileSync(filePath, "x".repeat(40)); - } - NodeFS.writeFileSync( - legacyLogPath, - "[2026-01-01T00:00:00.000Z] CANON: legacy provider event\n", - ); - NodeFS.utimesSync(expiredPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(legacyLogPath, (now - 20_000) / 1_000, (now - 20_000) / 1_000); - NodeFS.utimesSync(oldPath, (now - 5_000) / 1_000, (now - 5_000) / 1_000); - NodeFS.utimesSync(newPath, now / 1_000, now / 1_000); - - const store = yield* makeEventNdjsonLogStore(basePath, { - maxAgeMs: 10_000, - maxTotalBytes: 60, - }); - yield* store.close(); - - assert.equal(NodeFS.existsSync(expiredPath), false); - assert.equal(NodeFS.existsSync(legacyLogPath), false); - assert.equal(NodeFS.existsSync(oldPath), false); - assert.equal(NodeFS.existsSync(newPath), true); - assert.equal(NodeFS.existsSync(unrelatedLogPath), true); - assert.equal(NodeFS.existsSync(ignoredPath), true); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("does not prune an active thread sink during an unrelated flush", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - const activePath = ownedLogPath(basePath, "active"); - - try { - yield* TestClock.setTime(1_800_000_000_000); - const store = yield* makeEventNdjsonLogStore(basePath, { - batchWindowMs: 0, - maxAgeMs: 1, - retentionCheckIntervalMs: 1, - }); - const logger = store.logger("native"); - - yield* logger.write({ id: "active-before-retention" }, ThreadId.make("active")); - assert.equal(NodeFS.existsSync(activePath), true); - - yield* TestClock.adjust("2 millis"); - yield* logger.write({ id: "retention-trigger" }, ThreadId.make("other")); - - assert.equal(NodeFS.existsSync(activePath), true); - yield* store.close(); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("attributes batches that were written before a later chunk fails", () => Effect.gen(function* () { const records: ReadonlyArray = [ @@ -939,36 +524,4 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { } }), ); - - it.effect("reports logical provider log writes to resource attribution", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "provider-native.ndjson"); - - try { - const attribution = yield* ResourceAttribution.make(); - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "native", - batchWindowMs: 0, - attribution, - }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } - - yield* logger.write({ id: "attributed-event" }, ThreadId.make("thread-attribution")); - yield* logger.close(); - - const snapshot = yield* attribution.snapshot; - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0]?.component, "provider-event-log"); - assert.equal(snapshot.entries[0]?.operation, "native.append"); - assert.equal(snapshot.entries[0]?.count, 1); - assert.isAbove(snapshot.entries[0]?.logicalWriteBytes ?? 0, 0); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 093ecbe42e5d..373d9773280a 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -12,8 +12,6 @@ import { GrokSettings } from "@t3tools/provider-grok/settings"; import { buildGrokModelCapabilities, - buildGrokModelsFromSessionModelState, - buildInitialGrokProviderSnapshot, checkGrokProviderStatus, grokSlashCommandsFromInitialize, parseGrokModelsCliOutput, @@ -53,10 +51,6 @@ describe("parseGrokModelsCliOutput", () => { it("detects a logged-out CLI even though it exits 0", () => { expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); }); - - it("returns unknown auth for unrecognized output", () => { - expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); - }); }); describe("grokSlashCommandsFromInitialize", () => { @@ -110,101 +104,9 @@ describe("grokSlashCommandsFromInitialize", () => { expect(commands.map((command) => command.name)).toEqual(["compact", "session-info"]); expect(commands[1]?.description).toBe("Updated session details"); }); - - it("keeps compact available for older agents without command metadata", () => { - for (const _meta of [undefined, {}, { availableCommands: "invalid" }]) { - expect( - grokSlashCommandsFromInitialize({ protocolVersion: 1, ...(_meta ? { _meta } : {}) }).map( - (command) => command.name, - ), - ).toEqual(["compact"]); - } - }); -}); - -describe("buildGrokModelsFromSessionModelState", () => { - it("marks the agent's current model as default and keeps reasoning options", () => { - const models = buildGrokModelsFromSessionModelState({ - currentModelId: "grok-4.6", - availableModels: [ - { - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High", default: true }], - }, - }, - { modelId: "grok-4.5", name: "Grok 4.5" }, - ], - }); - expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); - }); }); describe("buildGrokModelCapabilities", () => { - it("preserves ACP-provided reasoning labels and the active default", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [ - { value: "xhigh", label: "Extra High Effort", default: true }, - { value: "high", label: "High Effort", default: true }, - { value: "medium", label: "Medium Effort" }, - { value: "low", label: "Low Effort" }, - ], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, - { id: "medium", label: "Medium Effort" }, - { id: "low", label: "Low Effort" }, - ], - }, - ]); - }); - - it("uses raw ACP values when option labels are omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - supportsReasoningEffort: true, - reasoningEffort: "xhigh", - reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], - }, - }); - - expect(capabilities.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "xhigh", - options: [ - { id: "xhigh", label: "xhigh" }, - { id: "medium", label: "medium" }, - ], - }, - ]); - }); - it("keeps ACP current effort separate from its collapsed advertised default", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", @@ -273,19 +175,6 @@ describe("buildGrokModelCapabilities", () => { ]); }); - it("accepts an advertised ACP menu when the support flag is omitted", () => { - const capabilities = buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { - reasoningEffort: "high", - reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], - }, - }); - - expect(capabilities.optionDescriptors).toHaveLength(1); - }); - it("honors an explicit ACP opt-out even when a menu is present", () => { const capabilities = buildGrokModelCapabilities({ modelId: "grok-4.6", @@ -298,77 +187,9 @@ describe("buildGrokModelCapabilities", () => { expect(capabilities.optionDescriptors).toEqual([]); }); - - it("does not synthesize a reasoning menu when ACP omits it", () => { - expect( - buildGrokModelCapabilities({ - modelId: "grok-4.6", - name: "Grok 4.6", - _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, - }).optionDescriptors, - ).toEqual([]); - }); - - it("keeps non-reasoning Grok models free of reasoning controls", () => { - expect( - buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, - ).toEqual([]); - }); -}); - -describe("buildInitialGrokProviderSnapshot", () => { - it.effect("returns a disabled snapshot when settings.enabled is false", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: false }), - ); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - expect(snapshot.installed).toBe(false); - expect(snapshot.message).toContain("disabled"); - }), - ); - - it.effect("returns a disabled snapshot by default — Grok is opt-in", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot(decodeGrokSettings({})); - expect(snapshot.enabled).toBe(false); - expect(snapshot.status).toBe("disabled"); - }), - ); - - it.effect("returns a pending snapshot when enabled", () => - Effect.gen(function* () { - const snapshot = yield* buildInitialGrokProviderSnapshot( - decodeGrokSettings({ enabled: true }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("warning"); - expect(snapshot.version).toBeNull(); - expect(snapshot.message).toContain("Checking Grok"); - expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); - expect(snapshot.supportsConversationRollback).toBe(false); - }), - ); }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { - it.effect("reports the binary as missing when the binary path does not resolve", () => - Effect.gen(function* () { - const snapshot = yield* checkGrokProviderStatus( - decodeGrokSettings({ - enabled: true, - binaryPath: "/definitely/not/installed/grok-binary", - }), - ); - expect(snapshot.enabled).toBe(true); - expect(snapshot.installed).toBe(false); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); - }), - ); - it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { const secretStderr = "broken grok install: secret-token-value"; @@ -482,28 +303,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports unauthenticated from `grok models` without starting a session", () => - Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_OUT_MODELS_OUTPUT, - acp: true, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); - - expect(snapshot.status).toBe("error"); - expect(snapshot.auth.status).toBe("unauthenticated"); - expect(snapshot.message).toContain("grok login"); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); - }), - ); - // An expired access token prints the logged-out banner, then the CLI refreshes it in the // same run, so the next `grok models` reports the login (#14285). it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => @@ -626,6 +425,7 @@ describe("Grok usage limits", () => { } }); + // regression: #17061 show zero-use Grok billing periods it("rejects impossible calendar dates at either period boundary", () => { for (const [start, end] of [ ["2026-02-30T00:00:00.000Z", "2026-03-09T00:00:00.000Z"], @@ -638,30 +438,6 @@ describe("Grok usage limits", () => { } }); - it("uses the reported subscription percentage and weekly reset", () => { - const limits = grokUsageResponseToLimits( - { - config: { - creditUsagePercent: 100, - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - end: "2026-09-18T03:10:30.159171+00:00", - }, - }, - }, - checkedAt, - ); - expect(limits.windows).toEqual([ - { - id: "subscription", - kind: "weekly", - label: "Weekly", - usedPercent: 100, - resetsAt: "2026-09-18T03:10:30.159Z", - }, - ]); - }); - it("does not invent allowance or reset dates when billing omits them", () => { for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { const limits = grokUsageResponseToLimits(response, checkedAt); @@ -750,50 +526,6 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { ), ); - it.effect("ignores malformed period starts without inventing usage", () => - Effect.gen(function* () { - for (const start of [null, 42]) { - for (const creditUsagePercent of [37.5, undefined]) { - const response = { - config: { - creditUsagePercent, - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - start, - end: "2026-09-18T03:10:30.159171+00:00", - }, - }, - }; - const { usageLimits } = yield* readGrokAccount({ - HOME: "/definitely/not/a/grok-home", - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(response))), - ), - ), - ); - expect.soft(usageLimits.unavailable, JSON.stringify(response)).toBeUndefined(); - expect.soft(usageLimits.windows, JSON.stringify(response)).toEqual( - creditUsagePercent === undefined - ? [] - : [ - { - id: "subscription", - kind: "weekly", - label: "Weekly", - usedPercent: creditUsagePercent, - resetsAt: "2026-09-18T03:10:30.159Z", - }, - ], - ); - } - } - }), - ); - it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => Effect.gen(function* () { for (const environment of [ @@ -871,38 +603,6 @@ it.layer(NodeServices.layer)("readGrokAccount", (it) => { }).pipe(Effect.scoped), ); - it.effect( - "does not request subscription limits for custom account or endpoint configuration", - () => - Effect.gen(function* () { - for (const config of [ - '[auth]\nprovider_command = "custom-auth"', - '[grok_com_config]\nissuer = "https://custom.example"', - 'endpoints.proxy = "https://custom.example"', - ]) { - const { usageLimits: limits } = yield* readGrokAccount({ - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: (path) => { - expect(path.endsWith("config.toml")).toBe(true); - return Effect.succeed(config); - }, - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request another account's quota")), - ), - ); - expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); - } - }), - ); - it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => Effect.gen(function* () { for (const response of [ diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 635e5aa78911..560b702db3aa 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -13,59 +13,6 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ModelManifest from "./ModelManifest.ts"; describe("resolveProviderCatalog", () => { - it("resolves generic model presentation through a reusable profile", () => { - const manifest: ModelManifest.ModelManifestData = { - version: 1, - currentModels: {}, - providers: { - synthetic: { - defaults: { chat: "model-next" }, - profiles: { - standard: { - capabilities: { - optionDescriptors: [ - { - id: "mode", - label: "Mode", - type: "select", - options: [{ id: "fast", label: "Fast", isDefault: true }], - }, - ], - }, - adapter: { opaque: true }, - }, - }, - models: [ - { - slug: "model-next", - name: "Model Next", - aliases: ["next"], - status: "current", - badge: "new", - profile: "standard", - }, - ], - }, - }, - }; - - const catalog = ModelManifest.resolveProviderCatalog( - manifest, - ProviderDriverKind.make("synthetic"), - ); - assert.deepStrictEqual(catalog?.models[0], { - slug: "model-next", - name: "Model Next", - aliases: ["next"], - badge: "new", - status: "current", - capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, - adapter: undefined, - profileAdapter: { opaque: true }, - }); - assert.strictEqual(catalog?.defaultChatModel, "model-next"); - }); - it("rejects invalid catalog references", () => { const invalidCatalog = (input: { readonly models: NonNullable[string]["models"]; @@ -236,84 +183,6 @@ const layerService = (input: { ); describe("ModelManifest service", () => { - it.live("explicit refresh bypasses fresh memory and disk caches", () => { - let fetchCount = 0; - const updated: ModelManifest.ModelManifestData = { - ...REMOTE_MANIFEST, - currentModels: { codex: ["gpt-reloaded"] }, - }; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 1); - - const rebooted = yield* ModelManifest.make; - assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 1); - assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); - assert.strictEqual(fetchCount, 2); - assert.deepStrictEqual(yield* rebooted.current, updated); - assert.deepStrictEqual(yield* (yield* ModelManifest.make).current, updated); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-refresh-test", - response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), - }), - ), - ); - }); - - it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { - let fetchCount = 0; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); - assert.deepStrictEqual(yield* (yield* ModelManifest.make).current, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 2); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 3); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-retry-test", - response: () => - fetchCount++ === 1 - ? new Response(null, { status: 503 }) - : Response.json(REMOTE_MANIFEST), - }), - ), - ); - }); - - it.live("explicit refresh bypasses the retry delay after an initial failure", () => { - let fetchCount = 0; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - assert.strictEqual(fetchCount, 1); - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - assert.strictEqual(fetchCount, 2); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-force-initial-retry-test", - response: () => - fetchCount++ === 0 - ? new Response(null, { status: 503 }) - : Response.json(REMOTE_MANIFEST), - }), - ), - ); - }); - it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* ModelManifest.make; @@ -357,21 +226,6 @@ describe("ModelManifest service", () => { ); }); - it.live("keeps the bundled manifest when the remote payload is malformed", () => - Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-malformed-test", - response: () => Response.json({ version: 999, nonsense: true }), - }), - ), - ), - ); - it.effect("preserves the last-good remote cache when later payloads are invalid", () => { let responseIndex = 0; const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; @@ -461,43 +315,3 @@ describe("ModelManifest service", () => { ), ); }); - -it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { - const remote: ModelManifest.ModelManifestData = { - ...REMOTE_MANIFEST, - compatibility: [ - { - driver: "codex", - t3CodeRange: ">=0.0.42", - recommendedVersion: "2.0.0", - ranges: [{ range: "=2.0.0", status: "supported" }], - }, - ], - }; - let invalid = false; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); - invalid = true; - yield* TestClock.adjust("1 hour"); - assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); - const rebooted = yield* ModelManifest.make; - assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-compatibility-test", - response: () => - Response.json( - invalid - ? { - ...remote, - compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], - } - : remote, - ), - }), - ), - ); -}); diff --git a/apps/server/src/provider/OpenCode2Client.events.test.ts b/apps/server/src/provider/OpenCode2Client.events.test.ts index 5ca237f2a669..ddc3bdbaa0c1 100644 --- a/apps/server/src/provider/OpenCode2Client.events.test.ts +++ b/apps/server/src/provider/OpenCode2Client.events.test.ts @@ -1,56 +1,15 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; import { HttpClient, HttpClientResponse } from "effect/http"; import { describe } from "vite-plus/test"; import * as OpenCode2Client from "@t3tools/provider-opencode/server/v2/OpenCode2Client"; -const RECORDING = new URL( - "../orchestration-v2/testkit/fixtures/opencode2_simple/opencode_transcript.ndjson", - import.meta.url, -); -const Entry = Schema.Struct({ type: Schema.String, frame: Schema.optional(Schema.Unknown) }); -const decodeEntry = Schema.decodeUnknownSync(Schema.fromJsonString(Entry)); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const newerServerStream = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const recorded = (yield* fs.readFileString(RECORDING.pathname)) - .split("\n") - .filter((line) => line.length > 0) - .map((line) => decodeEntry(line)) - .flatMap((entry) => { - const frame = entry.frame as { readonly type?: string; readonly event?: unknown } | undefined; - return frame?.type === "sdk.event" ? [frame.event as Record] : []; - }); - const executionStarted = recorded.findIndex( - (event) => event.type === "session.execution.started", - ); - const [first] = recorded; - return [ - ...recorded.slice(0, executionStarted), - { - id: "evt_0000000000newtype", - created: 1, - type: "session.hologram.projected", - data: { beams: 3 }, - }, - { - ...recorded[executionStarted], - data: { ...(recorded[executionStarted]!.data as object), lane: "fast" }, - }, - ...recorded.slice(executionStarted + 1), - first, - ]; -}); - const layerServing = (events: ReadonlyArray) => Layer.succeed( HttpClient.HttpClient, @@ -71,49 +30,7 @@ const connect = Effect.gen(function* () { return yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password: "secret" }); }); -const layerServingThenSilent = (events: ReadonlyArray) => - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - new ReadableStream({ - start(controller) { - for (const event of events) { - controller.enqueue(new TextEncoder().encode(`data: ${encodeJson(event)}\n\n`)); - } - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ), - ), - ), - ), - ); - describe("OpenCode2Client events", () => { - it.effect("fails a stream that stops sending anything, even heartbeats", () => - Effect.gen(function* () { - const { events } = yield* connect.pipe( - Effect.provide( - OpenCode2Client.layer.pipe( - Layer.provide( - layerServingThenSilent([{ id: "evt_1", type: "server.connected", data: {} }]), - ), - ), - ), - ); - const drained = yield* (yield* events).pipe(Stream.runDrain, Effect.flip, Effect.forkChild); - // Let the first frame arrive and the timer start before the clock moves. - for (let i = 0; i < 20; i++) yield* Effect.yieldNow; - yield* TestClock.adjust("46 seconds"); - const failure = yield* Fiber.join(drained); - assert.strictEqual(failure._tag, "OpenCode2SilentStreamError"); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect( "keeps an execution's end and start it cannot decode, and skips other unknown events", () => @@ -159,33 +76,4 @@ describe("OpenCode2Client events", () => { ]); }), ); - - it.effect("skips events a newer server adds and still sees the turn end", () => - Effect.gen(function* () { - const events = yield* newerServerStream; - const { events: subscribe } = yield* connect.pipe( - Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), - ); - const types = yield* (yield* subscribe).pipe( - Stream.map((event) => event.type), - Stream.runCollect, - ); - - assert.notInclude(types, "session.hologram.projected"); - assert.include(types, "session.execution.started"); - assert.include(types, "session.execution.succeeded"); - assert.strictEqual(types.length, events.length - 1); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("the client's own subscription fails the whole stream on the same events", () => - Effect.gen(function* () { - const events = yield* newerServerStream; - const { client } = yield* connect.pipe( - Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerServing(events)))), - ); - const failure = yield* client.event.subscribe().pipe(Stream.runDrain, Effect.flip); - assert.strictEqual(failure._tag, "ClientError"); - }).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts deleted file mode 100644 index 01fd35c0c598..000000000000 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts"; - -const liveProjectId = ProjectId.make("project-live"); -const deletedProjectId = ProjectId.make("project-deleted"); - -function settingsWith(overrides: Partial): ServerSettings { - return { ...DEFAULT_SERVER_SETTINGS, ...overrides }; -} - -describe("findOrphanedProjectIds", () => { - it("reports a project override whose project is no longer live", () => { - const settings = settingsWith({ - projectSettingsOverrides: { - [liveProjectId]: { providerInstances: {} }, - [deletedProjectId]: { providerInstances: {} }, - }, - }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([deletedProjectId]); - }); - - it("reports nothing when every override's project is still live", () => { - const settings = settingsWith({ - projectSettingsOverrides: { - [liveProjectId]: { providerInstances: {} }, - }, - }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([]); - }); - - it("reports nothing when there are no overrides at all", () => { - const settings = settingsWith({ projectSettingsOverrides: {} }); - - const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId])); - - expect(orphaned).toEqual([]); - }); -}); diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts index ae42e9b00c9c..aa7d20527149 100644 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as Settings from "../serverSettings.ts"; -export const findOrphanedProjectIds = ( +const findOrphanedProjectIds = ( settings: Pick, liveProjectIds: ReadonlySet, ): ReadonlyArray => diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts index 3bc8d1ea7eac..cecc23aa2c90 100644 --- a/apps/server/src/provider/ProviderAuthService.test.ts +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -451,27 +451,6 @@ describe("ProviderAuthService", () => { assert.include(error.detail, "does not accept"); }), ); - it.effect("stops routed sessions before sign-in, including for a disabled instance", () => - Effect.gen(function* () { - const { service, actions, released } = yield* makeHarness({ - enabled: false, - threads: [makeThread("active")], - sessions: new Map([[ThreadId.make("active"), [makeSession("active-session")]]]), - }); - const state = yield* service.start({ instanceId }, owner); - - assert.strictEqual(state.instanceId, instanceId); - assert.strictEqual(state.phase, "waiting"); - assert.deepStrictEqual(released, ["active-session"]); - assert.deepStrictEqual(actions, [ - "close-gate", - "list-threads", - "sessions:active", - "stop:active-session", - "start-sign-in", - ]); - }), - ); it.effect("keeps sign-in state private and accepts the owner's redirect URL", () => Effect.gen(function* () { @@ -689,42 +668,6 @@ describe("ProviderAuthService", () => { }), ); - it.effect.each([ - { text: "/logout", hasAttachments: false, handled: true }, - { text: " \n/logout\t", hasAttachments: false, handled: true }, - { text: "/logout", hasAttachments: true, handled: false }, - { text: "/logout please", hasAttachments: false, handled: false }, - { text: "Explain /logout", hasAttachments: false, handled: false }, - { text: "/Logout", hasAttachments: false, handled: false }, - ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments }); - - assert.strictEqual(result, handled); - assert.deepStrictEqual( - actions, - handled ? ["close-gate", "list-threads", "native-logout"] : [], - ); - }), - ); - - it.effect("does not intercept commands for providers without an auth controller", () => - Effect.gen(function* () { - const { service, actions } = yield* makeHarness(); - for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) { - assert.isFalse( - yield* service.tryHandlePromptCommand({ - instanceId: id, - text: "/logout", - hasAttachments: false, - }), - ); - } - assert.deepStrictEqual(actions, []); - }), - ); - it.effect("does not log out when the thread shell cannot be read", () => Effect.gen(function* () { const { service, actions } = yield* makeHarness({ shellError: true }); @@ -761,22 +704,6 @@ describe("ProviderAuthService", () => { ]); }), ); - - it.effect("returns native logout failures to the command caller", () => - Effect.gen(function* () { - const logoutError = new ProviderSetupError({ - instanceId, - operation: "logout", - detail: "Native sign-out failed. Try again.", - }); - const { service } = yield* makeHarness({ logoutError }); - const error = yield* Effect.flip( - service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }), - ); - - assert.strictEqual(error, logoutError); - }), - ); }); it.effect("queued logout prompts resolve the current controller after provider replacement", () => @@ -892,46 +819,6 @@ it.effect.each(["start", "logout", "prompt"] as const)( }).pipe(Effect.scoped), ); -it.effect.each([ - { owner: "provider" as const, key: "different-binding" }, - { owner: "t3" as const, key: "shared" }, -])( - "does not invalidate a peer that switches credential binding during session draining %#", - (binding) => - Effect.gen(function* () { - const draining = yield* Deferred.make(); - const continueDrain = yield* Deferred.make(); - const harness = yield* makeHarness({ - sharedCredentials: true, - ...sessionThreads([makeSession("shared-draining", "ready", otherInstanceId)]), - beforeStop: Deferred.succeed(draining, undefined).pipe( - Effect.andThen(Deferred.await(continueDrain)), - ), - }); - const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); - yield* Deferred.await(draining); - let replacementInvalidated = false; - harness.replaceInstance( - makeInstance({ - instanceId: otherInstanceId, - enabled: true, - auth: { - ...harness.auth, - credentialBinding: binding, - invalidate: Effect.sync(() => { - replacementInvalidated = true; - }), - }, - }), - ); - yield* Deferred.succeed(continueDrain, undefined); - yield* Fiber.join(logout); - assert.isFalse(replacementInvalidated); - assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); - assert.include(harness.actions, "native-logout"); - }).pipe(Effect.scoped), -); - it.effect.each(["selection", "drain"] as const)( "preserves replacement peer sessions when credentials change during session %s", (phase) => diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 5d5ff41e368f..9e6574a4e224 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -9,6 +9,8 @@ import { EnvironmentId, type ClaudeSettings, type CodexSettings, + type CustomAcpSettings, + type DevinSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, @@ -17,6 +19,15 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import type { GrokSettings } from "@t3tools/provider-grok/settings"; import type { CursorSettings } from "@t3tools/provider-cursor/settings"; +import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; +import * as AntigravityInstallation from "./AntigravityInstallation.ts"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; +import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; +import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; +import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; +import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; +import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.ts"; +import { CustomAcpDriver, type CustomAcpDriverEnv } from "./Drivers/CustomAcpDriver.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -28,18 +39,11 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; -import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; -import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; -import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; import * as ModelManifest from "./ModelManifest.ts"; -import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; -import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; @@ -99,16 +103,6 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ ...overrides, }); -const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ - enabled: false, - binaryPath: "claude", - homePath: "", - customModels: [], - launchArgs: "", - autoCompactWindow: "", - ...overrides, -}); - const makeCursorConfig = (overrides: Partial): CursorSettings => ({ enabled: false, customModels: [], @@ -133,6 +127,32 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeDevinConfig = (overrides: Partial): DevinSettings => ({ + enabled: false, + binaryPath: "devin", + homePath: "", + customModels: [], + ...overrides, +}); + +const makeCustomAcpConfig = (overrides: Partial): CustomAcpSettings => ({ + enabled: false, + binaryPath: "", + arguments: "", + customModels: [], + ...overrides, +}); + +const makeClaudeConfig = (overrides: Partial): ClaudeSettings => ({ + enabled: false, + binaryPath: "claude", + homePath: "", + customModels: [], + launchArgs: "", + autoCompactWindow: "", + ...overrides, +}); + const makeTildeProviderFixtures = Effect.fn( "ProviderInstanceRegistry.test.makeTildeProviderFixtures", )(function* () { @@ -845,12 +865,16 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const devinId = ProviderInstanceId.make("devin_default"); + const customAcpId = ProviderInstanceId.make("custom_acp_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const devinDriverKind = ProviderDriverKind.make("devin"); + const customAcpDriverKind = ProviderDriverKind.make("customAcp"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -886,12 +910,38 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [devinId]: { + driver: devinDriverKind, + displayName: "Devin", + enabled: false, + config: makeDevinConfig({}), + }, + [customAcpId]: { + driver: customAcpDriverKind, + displayName: "Custom ACP", + enabled: false, + config: makeCustomAcpConfig({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry< - CodexDriverEnv | ClaudeDriverEnv | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv + | CodexDriverEnv + | ClaudeDriverEnv + | CursorDriverEnv + | GrokDriverEnv + | OpenCodeDriverEnv + | DevinDriverEnv + | CustomAcpDriverEnv >({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [ + CodexDriver, + ClaudeDriver, + CursorDriver, + GrokDriver, + OpenCodeDriver, + DevinDriver, + CustomAcpDriver, + ], configMap, }); @@ -899,9 +949,9 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(7); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, devinId, customAcpId].toSorted(), ); const codex = yield* registry.getInstance(codexId); @@ -909,16 +959,22 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const devin = yield* registry.getInstance(devinId); + const customAcp = yield* registry.getInstance(customAcpId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(devin?.driverKind).toBe(devinDriverKind); + expect(customAcp?.driverKind).toBe(customAcpDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); + expect(devin?.displayName).toBe("Devin"); + expect(customAcp?.displayName).toBe("Custom ACP"); const adapters = [ codex!.orchestrationAdapter, @@ -926,6 +982,8 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { cursor!.orchestrationAdapter, grok!.orchestrationAdapter, openCode!.orchestrationAdapter, + devin!.orchestrationAdapter, + customAcp!.orchestrationAdapter, ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ @@ -934,6 +992,8 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { cursor!.textGeneration, grok!.textGeneration, openCode!.textGeneration, + devin!.textGeneration, + customAcp!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); const snapshots = [ @@ -942,6 +1002,8 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { cursor!.snapshot, grok!.snapshot, openCode!.snapshot, + devin!.snapshot, + customAcp!.snapshot, ]; expect(new Set(snapshots).size).toBe(snapshots.length); @@ -982,6 +1044,20 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { expect(openCodeSnapshot.continuation?.groupKey).toBe( `${openCodeDriverKind}:instance:${openCodeId}`, ); + + const devinSnapshot = yield* devin!.snapshot.getSnapshot; + expect(devinSnapshot.instanceId).toBe(devinId); + expect(devinSnapshot.driver).toBe(devinDriverKind); + expect(devinSnapshot.enabled).toBe(false); + expect(devinSnapshot.continuation?.groupKey).toBe(`${devinDriverKind}:instance:${devinId}`); + + const customAcpSnapshot = yield* customAcp!.snapshot.getSnapshot; + expect(customAcpSnapshot.instanceId).toBe(customAcpId); + expect(customAcpSnapshot.driver).toBe(customAcpDriverKind); + expect(customAcpSnapshot.enabled).toBe(false); + expect(customAcpSnapshot.continuation?.groupKey).toBe( + `${customAcpDriverKind}:instance:${customAcpId}`, + ); }).pipe(Effect.provide(layerTest)), ); }); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index 16b3958f3613..b825b29b674d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -54,21 +54,6 @@ describe("deriveProviderInstanceConfigMap", () => { }); }); - it("leaves an already machine-enabled instance untouched", () => { - const settings = settingsWith({ - providerInstances: { - [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }, - }); - - const merged = deriveProviderInstanceConfigMap(settings); - - expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); - }); - it("does not revive an instance a project override references but settings no longer configure", () => { const settings = settingsWith({ providerInstances: {}, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a8d7d6e7c528..035ace6573b4 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,16 +1,29 @@ -import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +function layerMockCommandSpawner( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { + command: string; + args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); + }), + ); +} import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert, expect } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; -import * as FileSystem from "effect/FileSystem"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -21,7 +34,6 @@ import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as CodexErrors from "effect-codex-app-server/errors"; import { EnvironmentId, ClaudeSettings, @@ -36,7 +48,6 @@ import { type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; -import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { deepMerge } from "@t3tools/shared/Struct"; @@ -45,15 +56,9 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; -import { ClaudeDriver } from "./Drivers/ClaudeDriver.ts"; -import { ClaudeAgentSdkQueryRunner } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityInstallation from "./AntigravityInstallation.ts"; import * as ModelManifest from "./ModelManifest.ts"; -import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -76,12 +81,8 @@ import type { } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; -import { ProviderDriverError } from "./Errors.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - makeManualOnlyProviderMaintenanceCapabilities, - makeProviderMaintenanceCapabilities, -} from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); const decodeServerProvider = Schema.decodeSync(ServerProvider); @@ -102,10 +103,6 @@ const disabledDefaultSlots = ( const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); -const decodeCodexSettings = Schema.decodeSync(CodexSettings); -const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ - enabled: false, -}); process.env.T3CODE_CURSOR_ENABLED = "1"; @@ -200,9 +197,6 @@ function claudeCapabilities(overrides: Partial = {}) { }); } -const noClaudeCapabilities = () => - Effect.sync(() => undefined as TestClaudeCapabilities | undefined); - function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -235,92 +229,6 @@ function layerMockSpawner( ); } -function recordingMockSpawnerLayer( - handler: (args: ReadonlyArray) => { - stdout: string; - stderr: string; - code: number; - }, -) { - const commands: Array<{ - readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv | undefined; - }> = []; - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - args: ReadonlyArray; - options?: { - readonly env?: NodeJS.ProcessEnv; - }; - }; - commands.push({ args: cmd.args, env: cmd.options?.env }); - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); - return { layer, commands }; -} - -function layerMockCommandSpawner( - handler: ( - command: string, - args: ReadonlyArray, - ) => { stdout: string; stderr: string; code: number }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { - command: string; - args: ReadonlyArray; - }; - return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); - }), - ); -} - -function layerFailingSpawner(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} - -function layerHangingScopedSpawner(killCalls: Ref.Ref) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const handle = ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Ref.update(killCalls, (current) => current + 1), - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.never, - stderr: Stream.never, - all: Stream.never, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); - return handle; - }), - ), - ); -} - const codexModelCapabilities = createModelCapabilities({ optionDescriptors: [ selectDescriptor("reasoningEffort", "Reasoning", [ @@ -486,21 +394,6 @@ it.layer( }), ); - it.effect("passes configured launch args to the Codex provider probe", () => - Effect.gen(function* () { - let observedLaunchArgs: string | undefined; - const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); - - const status = yield* checkCodexProviderStatus(settings, (input) => { - observedLaunchArgs = input.launchArgs; - return Effect.succeed(makeCodexProbeSnapshot()); - }); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(observedLaunchArgs, "--strict-config --enable foo"); - }), - ); - it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -540,114 +433,6 @@ it.layer( assert.strictEqual(status.auth.status, "unknown"); }), ); - - it.effect("returns an api key label for codex api key auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "apiKey" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "apiKey"); - assert.strictEqual(status.auth.label, "OpenAI API Key"); - }), - ); - - it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: { type: "amazonBedrock" }, - requiresOpenaiAuth: false, - }, - }), - ), - ); - - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "amazonBedrock"); - assert.strictEqual(status.auth.label, "Amazon Bedrock"); - }), - ); - - it.effect.each([ - "codex", - "/Applications/Custom App.app/Contents/Resources/codex", - "C:\\Tools\\codex.exe", - ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => - Effect.gen(function* () { - const settings = { ...defaultCodexSettings, binaryPath }; - const status = yield* checkCodexProviderStatus(settings, (input) => { - assert.strictEqual(input.binaryPath, binaryPath); - return Effect.fail( - new CodexErrors.CodexAppServerSpawnError({ - command: `${binaryPath} app-server`, - cause: new Error("spawn ENOENT"), - }), - ); - }); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.deepStrictEqual(status.setup, { canAuthenticate: false, canInstall: false }); - assert.include(status.message, binaryPath); - assert.include(status.message, "Settings → Providers → Codex → Binary path on the server"); - assert.strictEqual( - status.message?.includes("Installing ChatGPT or Codex desktop"), - binaryPath === "codex", - ); - assert.strictEqual(settings.binaryPath, binaryPath); - }), - ); - - it.effect( - "does not time out a healthy probe slower than the shared 10s auth-probe timeout (#7513)", - () => - Effect.gen(function* () { - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.sleep("15 seconds").pipe(Effect.as(makeCodexProbeSnapshot())), - ).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - yield* TestClock.adjust("15 seconds"); - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "ready"); - }), - ); - - it.effect("closes the app-server probe scope when provider status times out", () => - Effect.gen(function* () { - const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(layerHangingScopedSpawner(killCalls)), - Effect.forkChild, - ); - - yield* Effect.yieldNow; - yield* TestClock.adjust("21 seconds"); - yield* Effect.yieldNow; - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, "error"); - assert.strictEqual( - status.message, - "Timed out while checking Codex app-server provider status.", - ); - assert.strictEqual(yield* Ref.get(killCalls), 1); - }), - ); }); describe("ProviderRegistry.layer", () => { @@ -830,45 +615,6 @@ it.layer( ); }); - it("drops custom models the refreshed snapshot no longer carries", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2.1.0", - models: [ - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-custom", - name: "removed-custom", - isCustom: true, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [previousProvider.models[0]], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); - it("does not bring back models the installed CLI is too old to run", () => { const pendingProvider = { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -902,73 +648,6 @@ it.layer( ); }); - it("drops stale ACP Registry models missing from a completed discovery probe", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - { - slug: "gpt-5.6-sol[low]", - name: "GPT-5.6-Sol (low)", - isCustom: false, - capabilities: null, - }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], - nativeSessions: { canList: false, canLoad: false, canResume: false }, - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); - - it("drops stale ACP Registry models when an agent without sign-in completes discovery", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_kimi"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "unknown", canLogout: false }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "kimi-old", name: "Kimi Old", isCustom: false, capabilities: null }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], - nativeSessions: { canList: true, canLoad: true, canResume: false }, - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - // Agents that advertise no auth methods stay "unknown" after every probe. - const probedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "kimi-new", name: "Kimi New", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, probedProvider).models, - [...probedProvider.models], - ); - }); - it("retains ACP Registry models while discovery has not completed", () => { const previousProvider = { instanceId: ProviderInstanceId.make("acpRegistry_codex"), @@ -1025,28 +704,26 @@ it.layer( ); }); - it("drops stale OpenCode models missing from a successful refresh", () => { + it("drops stale Devin variant rows missing from a successful refresh", () => { const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("devin"), + driver: ProviderDriverKind.make("devin"), status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", + checkedAt: "2026-09-11T00:00:00.000Z", + version: "3000.10.21", models: [ { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", + slug: "swe-2-high", + name: "SWE-2 High", isCustom: false, capabilities: null, }, { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", + slug: "swe-2-medium", + name: "SWE-2 Medium", isCustom: false, capabilities: null, }, @@ -1056,58 +733,11 @@ it.layer( } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, - checkedAt: "2026-07-17T00:01:00.000Z", + checkedAt: "2026-09-11T00:01:00.000Z", models: [ { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); - - it("drops stale Devin variant rows missing from a successful refresh", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("devin"), - driver: ProviderDriverKind.make("devin"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-11T00:00:00.000Z", - version: "3000.10.21", - models: [ - { - slug: "swe-2-high", - name: "SWE-2 High", - isCustom: false, - capabilities: null, - }, - { - slug: "swe-2-medium", - name: "SWE-2 Medium", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-09-11T00:01:00.000Z", - models: [ - { - slug: "swe-2", - name: "SWE-2", + slug: "swe-2", + name: "SWE-2", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -1299,116 +929,6 @@ it.layer( ); }); - it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const pendingProvider = { - ...previousProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - version: null, - models: [], - message: "OpenCode provider status has not been checked in this session yet.", - } satisfies ServerProvider; - const loggedOutProvider = { - ...previousProvider, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - slashCommands: [], - skills: [], - message: "OpenCode is available, but it did not report any connected upstream providers.", - } satisfies ServerProvider; - const missingProvider = { - ...previousProvider, - status: "error", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:03:00.000Z", - version: null, - models: [], - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - } satisfies ServerProvider; - const authoritativeProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:04:00.000Z", - models: [previousProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:05:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, - [...previousProvider.models], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).models, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, - [], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, missingProvider).models, - [], - ); - - const afterRemoval = ProviderRegistry.mergeProviderSnapshot( - previousProvider, - authoritativeProvider, - ); - const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRemoval, failedProvider); - - assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); - }); - describe("Pi model inventories", () => { const defaultModel = { slug: "default", @@ -1472,27 +992,6 @@ it.layer( message: "Pi is available, but T3 Code could not refresh its models and commands.", } satisfies ServerProvider; - it("drops disconnected Pi upstream models after successful discovery", () => { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, refreshedProvider).models, - refreshedProvider.models, - ); - }); - - it("clears discovered Pi models when successful discovery finds no usable models", () => { - const signedOutProvider = { - ...refreshedProvider, - status: "warning", - auth: { status: "unauthenticated", type: "pi" }, - models: [defaultModel, customModel], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, signedOutProvider).models, - signedOutProvider.models, - ); - }); - it("retains Pi inventories during incomplete probes without restoring removed custom models", () => { const incompleteProviders = [ pendingProvider, @@ -1652,55 +1151,6 @@ it.layer( installed: true, } satisfies ServerProvider; - it("drops retired alpha models after discovery, including without OpenAI authentication", () => { - for (const authStatus of ["authenticated", "unknown"] as const) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, { - ...refreshedProvider, - auth: { status: authStatus }, - }).models, - refreshedProvider.models, - ); - } - }); - - it("keeps discovered models during startup, errors, and discovery warnings without restoring removed custom models", () => { - for (const provider of [ - pendingProvider, - failedProvider, - { ...failedProvider, status: "warning", auth: { status: "authenticated" } }, - ] satisfies ReadonlyArray) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot( - { - ...cachedProvider, - models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], - }, - provider, - ).models, - [customModel, ...cachedProvider.models], - ); - } - }); - - it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { - const emptyProvider = { ...refreshedProvider, models: [customModel] }; - const clearedProviders = [ - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - { ...emptyProvider, models: [] }, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(cachedProvider, provider).models, - provider.models, - ); - } - }); - it.effect("persists removals across failed refreshes and registry restarts", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1815,65 +1265,6 @@ it.layer( skills: [], } as const satisfies ServerProvider; - it("removes unavailable models after a successful refresh", () => { - for (const status of ["ready", "warning"] as const) { - const refreshedProvider = { - ...previousProvider, - status, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [previousProvider.models[1]], - } satisfies ServerProvider; - const afterRefresh = ProviderRegistry.mergeProviderSnapshot( - previousProvider, - refreshedProvider, - ); - - assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); - - const afterFailure = ProviderRegistry.mergeProviderSnapshot(afterRefresh, { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [], - }); - assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); - } - }); - - it("keeps cached models during health checks and temporary failures", () => { - for (const installed of [false, true]) { - const pendingProvider = { - ...previousProvider, - status: "warning", - installed, - auth: { status: "unknown" }, - checkedAt: "2026-09-02T00:01:00.000Z", - version: installed ? previousProvider.version : null, - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, pendingProvider).models, - previousProvider.models, - ); - } - - for (const authStatus of ["unknown", "authenticated"] as const) { - const failedProvider = { - ...previousProvider, - status: "error", - auth: { status: authStatus }, - checkedAt: "2026-09-02T00:02:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, - previousProvider.models, - ); - } - }); - it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { const emptyProvider = { ...previousProvider, @@ -1951,31 +1342,6 @@ it.layer( ); }); - it("carries the account through the boot probe and a failed probe without hiding them", () => { - const booting = { - ...restartProbe, - installed: false, - version: null, - message: "Checking Antigravity availability.", - } satisfies ServerProvider; - assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, booting), { - ...booting, - auth: signedIn.auth, - models: signedIn.models, - }); - - const failed = { - ...restartProbe, - status: "error", - message: "Antigravity did not respond to its local health check within 90 seconds.", - } satisfies ServerProvider; - assert.deepStrictEqual(ProviderRegistry.mergeProviderSnapshot(signedIn, failed), { - ...failed, - auth: signedIn.auth, - models: signedIn.models, - }); - }); - it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { const untouched = [ { ...restartProbe, auth: { status: "unauthenticated" } }, @@ -2064,675 +1430,48 @@ it.layer( ); }); - it.effect("does not run provider probes during layer construction", () => + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const initialProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "warning", + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const healthyProvider = { + instanceId, + driver, + status: "ready", enabled: true, - installed: false, - auth: { status: "unknown" }, + installed: true, + auth: { status: "authenticated" }, checkedAt: "2026-06-10T00:00:00.000Z", - version: null, - message: "Checking Codex provider status.", + version: "1.0.0", models: [], - slashCommands: [], + slashCommands: [COMPACT_SLASH_COMMAND], skills: [], } as const satisfies ServerProvider; - const refreshCalls = yield* Ref.make(0); - const instance = { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, + // Codex's account check failed; its skill scan did not. + const unhealthyProvider = { + ...healthyProvider, + status: "error", + auth: { status: "unknown" }, + message: "Codex app-server provider probe failed: unauthorized (401).", + slashCommands: [], + } as const satisfies ServerProvider; + const firstSkills = [{ name: "first", path: "/workspace/first/SKILL.md", enabled: true }]; + const laterSkills = [{ name: "later", path: "/workspace/later/SKILL.md", enabled: true }]; + const scanResult = yield* Ref.make({ + ...healthyProvider, + skills: firstSkills, + }); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, displayName: undefined, enabled: true, snapshot: { resolveMaintenance: () => Effect.succeed( makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(initialProvider), - refresh: Ref.update(refreshCalls, (count) => count + 1).pipe( - Effect.andThen(Effect.never), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - } satisfies ProviderInstance; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instanceId === codexInstanceId ? instance : undefined), - listInstances: Effect.succeed([instance]), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-background-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); - assert.strictEqual(yield* Ref.get(refreshCalls), 0); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - const updateScenarios = [ - "healthy", - "unauthenticated", - "sibling defects", - "selected defect", - "selected disabled", - ] as const; - it.effect.each(updateScenarios)("refreshes account caches after update (%s)", (scenario) => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("example"); - const packageName = "@example/provider"; - const manifestRefreshes = yield* Ref.make(0); - const events: string[] = []; - const manifest = { - version: 1, - currentModels: {}, - compatibility: [{ driver, t3CodeRange: ">=0.0.42", ranges: [] }], - } satisfies ModelManifest.ModelManifestData; - const makeInstance = ( - id: string, - config: { binaryPath: string; home: string; model: string }, - enabled = true, - driverKind = driver, - ): ProviderInstance => { - const instanceId = ProviderInstanceId.make(id); - const provider = { - instanceId, - driver: driverKind, - status: scenario === "unauthenticated" ? "error" : "ready", - enabled, - installed: true, - auth: { - status: - scenario === "unauthenticated" || scenario === "selected defect" - ? "unauthenticated" - : "authenticated", - label: config.home, - }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: scenario === "selected defect" && id === "personal" ? "2.0.0" : "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - let cached = true; - return { - instanceId, - driverKind, - continuationIdentity: { driverKind, continuationKey: config.home }, - displayName: undefined, - enabled, - snapshot: { - resolveMaintenance: () => - Effect.sync(() => { - if (id === "broken-maintenance" && events.includes("update")) { - throw new Error("Sibling maintenance failed"); - } - return makeProviderMaintenanceCapabilities({ - provider: driverKind, - packageName: scenario === "selected defect" ? null : packageName, - updateExecutable: config.binaryPath, - updateArgs: ["update"], - env: { PROVIDER_HOME: config.home }, - updateLockKey: config.binaryPath, - latestVersion: scenario === "selected defect" ? null : "2.0.0", - }); - }), - getSnapshot: Effect.succeed(provider), - refresh: Effect.sync(() => { - events.push(`probe:${id}`); - if ( - id === "broken-discovery" || - (scenario === "selected defect" && id === "personal") - ) { - throw new Error("Provider discovery failed"); - } - return cached - ? provider - : { - ...provider, - version: "2.0.0", - models: [ - { - slug: config.model, - name: config.model, - isCustom: false, - capabilities: createModelCapabilities({ optionDescriptors: [] }), - }, - ], - }; - }), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - invalidateCaches: Effect.sync(() => { - cached = false; - events.push(`invalidate:${id}`); - }), - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }; - }; - const instances = [ - makeInstance( - "personal", - { binaryPath: "/personal/agent", home: "/personal", model: "personal-model" }, - scenario !== "selected disabled", - ), - makeInstance("work", { binaryPath: "/work/agent", home: "/work", model: "work-model" }), - ...(scenario === "sibling defects" - ? ["broken-maintenance", "broken-discovery"].map((id) => - makeInstance(id, { binaryPath: `/${id}/agent`, home: `/${id}`, model: id }), - ) - : []), - makeInstance( - "disabled", - { binaryPath: "/disabled/agent", home: "/disabled", model: "disabled-model" }, - false, - ), - makeInstance( - "unrelated", - { binaryPath: "/other/agent", home: "/other", model: "other-model" }, - true, - ProviderDriverKind.make("other"), - ), - ]; - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderMaintenanceRunner.layer.pipe( - Layer.provideMerge(ProviderRegistry.layer), - Layer.provideMerge( - Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, { - getInstance: (instanceId) => - Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }), - ), - Layer.provideMerge( - Layer.succeed(ModelManifest.ModelManifest, { - current: Effect.succeed(manifest), - refresh: Effect.succeed(manifest), - forceRefresh: Ref.update(manifestRefreshes, (count) => count + 1).pipe( - Effect.as(manifest), - ), - refreshInBackground: Effect.void, - }), - ), - Layer.provideMerge( - Layer.effect( - ProviderLatestVersions.ProviderLatestVersions, - ProviderLatestVersions.make([ - [packageName, "1.0.0"], - ["unrelated-package", "1.0.0"], - ]), - ), - ), - Layer.provideMerge(Layer.succeed(HostProcess.Platform, "linux")), - Layer.provideMerge( - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - assert.strictEqual(command._tag, "StandardCommand"); - if (command._tag !== "StandardCommand") throw new Error("Unexpected pipeline"); - assert.strictEqual(command.command, "/personal/agent"); - assert.deepStrictEqual(command.args, ["update"]); - assert.strictEqual(command.options.env?.PROVIDER_HOME, "/personal"); - events.push("update"); - return mockHandle({ stdout: "updated", stderr: "", code: 0 }); - }), - ), - ), - ), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-update-caches-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const runner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; - const registry = yield* ProviderRegistry.ProviderRegistry; - const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; - const before = yield* registry.getProviders; - if (scenario === "selected defect") { - const selected = before.find((p) => p.instanceId === "personal"); - assert.strictEqual(selected?.version, "2.0.0"); - assert.strictEqual(selected?.status, "ready"); - assert.strictEqual(selected?.auth.status, "unauthenticated"); - assert.strictEqual(selected?.versionAdvisory, undefined); - } - const result = yield* runner.updateProvider({ - provider: driver, - instanceId: ProviderInstanceId.make("personal"), - }); - assert.strictEqual( - result.providers.find((p) => p.instanceId === "personal")?.updateState?.status, - scenario === "selected defect" ? "unchanged" : "succeeded", - ); - assert.deepStrictEqual( - events.filter((event) => event === "update"), - ["update"], - ); - assert.strictEqual(events[0], "update"); - assert.deepStrictEqual( - events.filter((event) => event.startsWith("probe:")).toSorted(), - [ - "probe:personal", - "probe:work", - ...(scenario === "sibling defects" ? ["probe:broken-discovery"] : []), - ].toSorted(), - ); - for (const id of ["personal", "work"]) { - assert.isBelow(events.indexOf(`invalidate:${id}`), events.indexOf(`probe:${id}`)); - assert.include(events, `invalidate:${id}`); - const provider = result.providers.find((p) => p.instanceId === id); - assert.deepStrictEqual( - provider?.models.map((model) => model.slug), - scenario === "selected defect" && id === "personal" ? [] : [`${id}-model`], - ); - assert.strictEqual(provider?.auth.label, `/${id}`); - if (scenario === "unauthenticated") { - assert.strictEqual(provider?.status, "error"); - assert.strictEqual(provider?.auth.status, "unauthenticated"); - assert.strictEqual(provider?.version, "2.0.0"); - } - } - for (const id of scenario === "sibling defects" - ? ["broken-maintenance", "broken-discovery"] - : scenario === "selected defect" - ? ["personal"] - : []) { - assert.strictEqual(result.providers.find((p) => p.instanceId === id)?.status, "error"); - } - assert.isAtLeast(yield* Ref.get(manifestRefreshes), 1); - assert.strictEqual( - yield* latestVersions.cached(packageName, Effect.succeed("2.0.0")), - scenario === "selected defect" ? "1.0.0" : "2.0.0", - ); - assert.strictEqual( - yield* latestVersions.cached("unrelated-package", Effect.succeed("2.0.0")), - "1.0.0", - ); - assert.strictEqual( - result.providers.find((p) => p.instanceId === "work")?.updateState, - undefined, - ); - for (const id of ["disabled", "unrelated"]) { - assert.notInclude(events, `invalidate:${id}`); - assert.deepStrictEqual( - result.providers.find((p) => p.instanceId === id), - before.find((p) => p.instanceId === id), - ); - } - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const machineProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [{ name: "global" }], - skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const scopedProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:01:00.000Z", - slashCommands: [{ name: "project" }], - skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], - } as const satisfies ServerProvider; - const snapshotCalls = yield* Ref.make(0); - const scopedResult = yield* Ref.make({ - ...scopedProvider, - status: "error", - slashCommands: [COMPACT_SLASH_COMMAND], - slashCommandsPending: true, - }); - const cacheInvalidations = yield* Ref.make(0); - const scanGate = yield* Ref.make<{ - readonly started: Deferred.Deferred; - readonly release: Deferred.Deferred; - } | null>(null); - const failScan = yield* Ref.make(true); - const probeStarted = yield* Deferred.make(); - const releaseProbe = yield* Deferred.make(); - const makeInstance = ( - provider: ServerProvider, - snapshotForCwd: NonNullable, - ): ProviderInstance => ({ - instanceId, - driverKind: driver, - continuationIdentity: { - driverKind: driver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - snapshotForCwd, - invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const firstInstance = makeInstance(machineProvider, () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(failScan)) { - return yield* new ProviderDriverError({ - driver, - instanceId, - detail: "The workspace scan failed.", - }); - } - yield* Deferred.succeed(probeStarted, undefined); - yield* Deferred.await(releaseProbe); - const result = yield* Ref.get(scopedResult); - const gate = yield* Ref.getAndSet(scanGate, null); - if (gate) { - yield* Deferred.succeed(gate.started, undefined); - yield* Deferred.await(gate.release); - } - return result; - }), - ); - const rebuiltProvider = { - ...machineProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - status: "warning", - installed: false, - auth: { status: "unknown" }, - } satisfies ServerProvider; - const rebuiltInstance = makeInstance(rebuiltProvider, () => - Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), - ); - const registryChanges = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([firstInstance]); - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (requestedId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === requestedId), - ), - ), - listInstances: Ref.get(instancesRef), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(registryChanges), - subscribeChanges: PubSub.subscribe(registryChanges), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-workspace-snapshot-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); - yield* Ref.set(failScan, false); - const workspaceUpdate = yield* registry.streamChanges.pipe( - Stream.runHead, - Effect.forkChild, - ); - yield* Effect.yieldNow; - const firstRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Deferred.await(probeStarted); - const duplicateRefresh = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) - .pipe(Effect.forkChild); - yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); - yield* Deferred.succeed(releaseProbe, undefined); - yield* Fiber.join(firstRefresh); - yield* Fiber.join(duplicateRefresh); - const published = yield* Fiber.join(workspaceUpdate); - assert.strictEqual(published._tag, "Some"); - const providers = yield* registry.getProviders; - assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); - assert.deepStrictEqual( - providers[0]?.workspaceSnapshots?.[0]?.skills, - scopedProvider.skills, - ); - assert.deepStrictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommands, [ - COMPACT_SLASH_COMMAND, - ]); - assert.strictEqual(providers[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, true); - yield* Ref.set(scopedResult, { - ...scopedProvider, - status: "error", - slashCommandsPending: false, - }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommands, - scopedProvider.slashCommands, - ); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]?.slashCommandsPending, - undefined, - ); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS - 1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 3); - yield* TestClock.adjust(1); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 4); - const newSkills = [ - ...scopedProvider.skills, - { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 5); - assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [newSkills], - ); - - const slowStarted = yield* Deferred.make(); - const releaseSlow = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); - yield* Ref.set(scopedResult, scopedProvider); - const slowScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(slowStarted); - const latestSkills = [ - ...newSkills, - { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, - ]; - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - yield* registry.refreshWorkspaceSnapshot({ - instanceId, - cwd: "/workspace", - fresh: true, - }); - yield* Deferred.succeed(releaseSlow, undefined); - yield* Fiber.join(slowScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), - [latestSkills], - ); - - // With no cached snapshot, an older scan must leave publication and - // request deduplication to the forced scan even when it finishes first. - const olderStarted = yield* Deferred.make(); - const releaseOlder = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: olderStarted, release: releaseOlder }); - yield* Ref.set(scopedResult, scopedProvider); - const olderScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2" }) - .pipe(Effect.forkChild); - yield* Deferred.await(olderStarted); - const newerStarted = yield* Deferred.make(); - const releaseNewer = yield* Deferred.make(); - yield* Ref.set(scanGate, { started: newerStarted, release: releaseNewer }); - yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); - const newerScan = yield* registry - .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2", fresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.await(newerStarted); - yield* Deferred.succeed(releaseOlder, undefined); - yield* Fiber.join(olderScan); - assert.strictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (snapshot) => snapshot.cwd === "/workspace2", - ), - undefined, - ); - const callsWhileForced = yield* Ref.get(snapshotCalls); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace2" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), callsWhileForced); - yield* Deferred.succeed(releaseNewer, undefined); - yield* Fiber.join(newerScan); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots?.find( - (snapshot) => snapshot.cwd === "/workspace2", - )?.skills, - latestSkills, - ); - - yield* Ref.set(instancesRef, [rebuiltInstance]); - yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } - assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); - assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("publishes workspace skills while machine health is in error", () => - Effect.gen(function* () { - const driver = ProviderDriverKind.make("codex"); - const instanceId = ProviderInstanceId.make("codex"); - const healthyProvider = { - instanceId, - driver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [COMPACT_SLASH_COMMAND], - skills: [], - } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. - const unhealthyProvider = { - ...healthyProvider, - status: "error", - auth: { status: "unknown" }, - message: "Codex app-server provider probe failed: unauthorized (401).", - slashCommands: [], - } as const satisfies ServerProvider; - const firstSkills = [{ name: "first", path: "/workspace/first/SKILL.md", enabled: true }]; - const laterSkills = [{ name: "later", path: "/workspace/later/SKILL.md", enabled: true }]; - const scanResult = yield* Ref.make({ - ...healthyProvider, - skills: firstSkills, - }); - const instance: ProviderInstance = { - instanceId, - driverKind: driver, - continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: driver, + provider: driver, packageName: null, }), ), @@ -2811,265 +1550,17 @@ it.layer( assert.deepStrictEqual(discovered?.slashCommands, [discoveredCommand]); assert.strictEqual(discovered?.slashCommandsPending, undefined); - // Once health recovers, the next scan restores the full entry. - yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); - yield* Ref.set(scanResult, { ...healthyProvider, skills: laterSkills }); - yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - const recovered = yield* workspaceOf; - assert.deepStrictEqual(recovered?.skills, laterSkills); - assert.deepStrictEqual(recovered?.slashCommands, [COMPACT_SLASH_COMMAND]); - assert.strictEqual(recovered?.slashCommandsPending, undefined); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("refreshes OpenCode catalogs and preserves other providers", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const openCodeDriver = ProviderDriverKind.make("opencode"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const openCodeInstanceId = ProviderInstanceId.make("opencode"); - const codexRefreshCalls = yield* Ref.make(0); - const openCodeRefreshCalls = yield* Ref.make(0); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const failedOpenCodeProvider = { - instanceId: openCodeInstanceId, - driver: openCodeDriver, - status: "error", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-06-10T00:00:00.000Z", - version: "1.0.0", - message: "Failed to refresh OpenCode models.", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const recoveredOpenCodeProvider = { - ...failedOpenCodeProvider, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-06-10T00:01:00.000Z", - message: "One upstream provider connected through OpenCode.", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const changedCatalogProvider = { - ...recoveredOpenCodeProvider, - checkedAt: "2026-06-10T00:02:00.000Z", - models: [ - { - slug: "anthropic/claude-sonnet-4", - name: "Claude Sonnet 4", - subProvider: "Anthropic", - isCustom: false, - capabilities: null, - }, - ], - } as const satisfies ServerProvider; - const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); - const instances = [ - { - instanceId: codexInstanceId, - driverKind: codexDriver, - continuationIdentity: { - driverKind: codexDriver, - continuationKey: "codex:instance:codex", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: codexDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(codexProvider), - refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( - Effect.as(codexProvider), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - { - instanceId: openCodeInstanceId, - driverKind: openCodeDriver, - continuationIdentity: { - driverKind: openCodeDriver, - continuationKey: "opencode:instance:opencode", - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: openCodeDriver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(failedOpenCodeProvider), - refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( - Effect.andThen(Ref.get(catalogSnapshot)), - ), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }, - ] satisfies ReadonlyArray; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-reconnect-refresh-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - const initialProviders = yield* registry.getProviders; - assert.strictEqual( - initialProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.status, - "error", - ); - - const recoveredProviders = yield* registry.refresh(); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) - ?.models, - recoveredOpenCodeProvider.models, - ); - assert.deepStrictEqual( - recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - - yield* Ref.set(catalogSnapshot, changedCatalogProvider); - const changedProviders = yield* registry.refresh(); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === openCodeInstanceId)?.models, - changedCatalogProvider.models, - ); - assert.deepStrictEqual( - changedProviders.find((provider) => provider.instanceId === codexInstanceId), - withBundledCompatibility(codexProvider), - ); - }).pipe(Effect.provide(runtimeServices)); - - assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); - assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); - }), - ); - - it("persists merged provider snapshots for the providers that were refreshed", () => { - const previousProviders = [ - { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - const refreshedCursor = { - ...previousProviders[0], - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - - const mergedProviders = ProviderRegistry.mergeProviderSnapshots(previousProviders, [ - refreshedCursor, - ]); - const persistedProviders = ProviderRegistry.selectProvidersByKind( - mergedProviders, - new Set([ProviderDriverKind.make("cursor")]), - ); - - assert.deepStrictEqual(persistedProviders, [ - { - ...refreshedCursor, - models: [...previousProviders[0].models], - }, - ]); - }); + // Once health recovers, the next scan restores the full entry. + yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); + yield* Ref.set(scanResult, { ...healthyProvider, skills: laterSkills }); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const recovered = yield* workspaceOf; + assert.deepStrictEqual(recovered?.skills, laterSkills); + assert.deepStrictEqual(recovered?.slashCommands, [COMPACT_SLASH_COMMAND]); + assert.strictEqual(recovered?.slashCommandsPending, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => Effect.gen(function* () { @@ -3499,226 +1990,6 @@ it.layer( }), ); - it.effect("keeps consuming registry changes after one sync fails", () => - Effect.gen(function* () { - const codexDriver = ProviderDriverKind.make("codex"); - const codexInstanceId = ProviderInstanceId.make("codex"); - const claudeDriver = ProviderDriverKind.make("claudeAgent"); - const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); - const codexProvider = { - instanceId: codexInstanceId, - driver: codexDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const claudeProvider = { - instanceId: claudeInstanceId, - driver: claudeDriver, - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-29T10:01:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const makeInstance = (provider: ServerProvider): ProviderInstance => ({ - instanceId: provider.instanceId, - driverKind: provider.driver, - continuationIdentity: { - driverKind: provider.driver, - continuationKey: `${provider.driver}:instance:${provider.instanceId}`, - }, - displayName: undefined, - enabled: true, - snapshot: { - resolveMaintenance: () => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ - provider: provider.driver, - packageName: null, - }), - ), - getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), - streamChanges: Stream.empty, - applyUsageLimits: () => Effect.void, - }, - orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], - textGeneration: {} as ProviderInstance["textGeneration"], - }); - const codexInstance = makeInstance(codexProvider); - const claudeInstance = makeInstance(claudeProvider); - const changes = yield* PubSub.unbounded(); - const instancesRef = yield* Ref.make>([codexInstance]); - const failNextList = yield* Ref.make(false); - const wait = () => Effect.yieldNow; - const layerInstanceRegistry = Layer.succeed( - ProviderInstanceRegistry.ProviderInstanceRegistry, - { - getInstance: (instanceId) => - Ref.get(instancesRef).pipe( - Effect.map((instances) => - instances.find((instance) => instance.instanceId === instanceId), - ), - ), - listInstances: Effect.gen(function* () { - const shouldFail = yield* Ref.get(failNextList); - if (shouldFail) { - yield* Ref.set(failNextList, false); - return yield* Effect.die(new Error("simulated registry list failure")); - } - return yield* Ref.get(instancesRef); - }), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.fromPubSub(changes), - subscribeChanges: PubSub.subscribe(changes), - }, - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const runtimeServices = yield* Layer.build( - ProviderRegistry.layer.pipe( - Layer.provideMerge(layerInstanceRegistry), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-sync-failure-", - }), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - Layer.provideMerge(NodeServices.layer), - ), - ).pipe(Scope.provide(scope)); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [ - withBundledCompatibility(codexProvider), - ]); - - yield* Ref.set(failNextList, true); - yield* PubSub.publish(changes, undefined); - - yield* Ref.set(instancesRef, [codexInstance, claudeInstance]); - yield* PubSub.publish(changes, undefined); - - let providers = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && !providers.some((provider) => provider.instanceId === claudeInstanceId); - attempt += 1 - ) { - yield* wait(); - providers = yield* registry.getProviders; - } - - assert.deepStrictEqual( - providers.map((provider) => provider.instanceId).toSorted(), - [codexInstanceId, claudeInstanceId].toSorted(), - ); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("propagates real Codex probe failures to the aggregator at boot", () => - Effect.gen(function* () { - const missingBinary = `t3code_codex_missing_`; - const serverSettings = yield* makeMutableServerSettingsService( - decodeServerSettings( - deepMerge(encodedDefaultServerSettings, { - providerInstances: { - ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), - codex_personal: { - driver: "codex", - displayName: "Codex Personal", - enabled: true, - config: { - binaryPath: missingBinary, - homePath: `/tmp/${missingBinary}_home`, - }, - }, - } as unknown as ContractServerSettings["providerInstances"], - }), - ), - ); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), - Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), - Layer.provideMerge( - Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), - ), - Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-provider-registry-", - }), - ), - Layer.provideMerge(layerTestHttpClient), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge(ProviderLatestVersions.layer), - Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(ResetCreditCoordinator.layerTest), - Layer.provideMerge( - OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), - ), - Layer.provideMerge(layerBackgroundPolicyAlwaysRun), - ); - const runtimeServices = yield* Layer.build(layerProviderRegistry).pipe( - Scope.provide(scope), - ); - - yield* Effect.gen(function* () { - const registry = yield* ProviderRegistry.ProviderRegistry; - let providers = yield* registry.getProviders; - for ( - let attempts = 0; - attempts < 50 && - providers.find((provider) => provider.instanceId === "codex_personal")?.status !== - "error"; - attempts += 1 - ) { - yield* Effect.yieldNow; - providers = yield* registry.getProviders; - } - const codexPersonal = providers.find( - (provider) => provider.instanceId === "codex_personal", - ); - assert.notStrictEqual( - codexPersonal, - undefined, - `Expected the aggregator to know about codex_personal; instead saw: ${providers - .map((provider) => provider.instanceId) - .join(", ")}`, - ); - assert.strictEqual( - codexPersonal?.status, - "error", - "Real Codex probe against a missing binary should surface as 'error' in the aggregator", - ); - assert.strictEqual(codexPersonal?.installed, false); - assert.include(codexPersonal?.message, missingBinary); - assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - it.effect("re-probes when settings change the codex binaryPath", () => Effect.gen(function* () { const firstMissing = `t3code_codex_first_`; @@ -4143,222 +2414,23 @@ it.layer( "cursor", "devin", "grok", - "muse", - "opencode", - "pi", - ]); - assert.strictEqual(cursorProvider?.enabled, false); - assert.strictEqual(cursorProvider?.status, "disabled"); - assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); - const museProvider = providers.find((provider) => provider.driver === "muse"); - assert.strictEqual(museProvider?.enabled, false); - assert.strictEqual(museProvider?.status, "disabled"); - assert.strictEqual(cursorSpawned, false); - }).pipe(Effect.provide(runtimeServices)); - }), - ); - - it.effect("skips codex probes entirely when the provider is disabled", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( - Effect.provide(layerFailingSpawner("spawn codex ENOENT")), - ); - assert.strictEqual(status.enabled, false); - assert.strictEqual(status.status, "disabled"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); - }), - ); - }); - - describe("checkClaudeProviderStatus", () => { - it.effect("keeps live usage through a refresh of cached Claude capabilities", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-usage-cache-" }); - const initializationStarted = yield* Deferred.make(); - const usageStarted = yield* Deferred.make(); - const initialization = Promise.withResolvers(); - const usage = Promise.withResolvers(); - const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => { - Deferred.doneUnsafe(initializationStarted, Effect.void); - return initialization.promise; - }, - usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { - Deferred.doneUnsafe(usageStarted, Effect.void); - return usage.promise; - }, - }) as ReturnType, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - - const instance = yield* ClaudeDriver.create({ - instanceId: ProviderInstanceId.make("claude-usage-cache"), - displayName: undefined, - environment: [], - enabled: true, - config: { ...defaultClaudeSettings, homePath }, - }); - yield* Deferred.await(initializationStarted); - yield* TestClock.adjust("1 second"); - const usageCheckedAt = DateTime.formatIso(yield* DateTime.now); - initialization.resolve({ - account: { subscriptionType: "max", tokenSource: "oauth" }, - commands: [], - agents: [], - output_style: "default", - available_output_styles: [], - models: [], - }); - yield* Deferred.await(usageStarted); - yield* TestClock.adjust("1 second"); - usage.resolve({ - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: null } }, - } as ClaudeSdk.SDKControlGetUsageResponse); - - const first = yield* instance.snapshot.refresh; - assert.strictEqual(first.status, "ready"); - const firstWindow = first.usageLimits?.windows[0]; - assert.isDefined(firstWindow); - assert.strictEqual(firstWindow?.usedPercent, 12); - - yield* TestClock.adjust("1 second"); - const liveCheckedAt = DateTime.formatIso(yield* DateTime.now); - yield* instance.snapshot.applyUsageLimits({ - checkedAt: liveCheckedAt, - windows: [{ ...firstWindow!, usedPercent: 95 }], - }); - const live = yield* instance.snapshot.getSnapshot; - yield* TestClock.adjust("1 second"); - const refreshCheckedAt = DateTime.formatIso(yield* DateTime.now); - const refreshed = yield* instance.snapshot.refresh; - - assert.strictEqual(query.mock.calls.length, 1); - assert.strictEqual(refreshed.checkedAt, refreshCheckedAt); - assert.deepStrictEqual(refreshed.usageLimits, live.usageLimits); - assert.strictEqual(refreshed.usageLimits?.checkedAt, liveCheckedAt); - assert.strictEqual(refreshed.usageLimits?.windows[0]?.usedPercent, 95); - assert.strictEqual(first.usageLimits?.checkedAt, usageCheckedAt); - }).pipe( - Effect.provide( - Layer.mergeAll( - TestProviderHost.layer({ runBackgroundWork: false }), - ServerConfig.layerTest(process.cwd(), { prefix: "t3-claude-usage-cache-" }), - IdAllocator.layer, - McpProviderSessions.layer, - ResetCreditCoordinator.layerTest, - ProviderLatestVersions.layer, - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - Layer.mock(ClaudeAgentSdkQueryRunner)({ - open: () => Effect.die("Usage probing must not start a conversation"), - }), - ).pipe( - Layer.provideMerge( - layerMockSpawner((args) => { - if (args.join(" ") === "--version") { - return { stdout: "2.1.288\n", stderr: "", code: 0 }; - } - throw new Error(`Unexpected args: ${args.join(" ")}`); - }), - ), - ), - ), - ), - ); - - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect.each([ - { - name: "accepts a healthy claude --version response after 19 seconds", - answer: Effect.sleep("19 seconds"), - elapsed: 19_000, - status: "ready", - version: "1.0.0", - }, - { - name: "bounds a hung claude --version probe", - answer: Effect.never, - elapsed: 21_000, - status: "error", - version: null, - }, - ])("$name", ({ answer, elapsed, status: expectedStatus, version }) => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const statusFiber = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ).pipe( - Effect.provide( - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - assert.deepStrictEqual( - (command as unknown as { args: ReadonlyArray }).args, - ["--version"], - ); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - ...mockHandle({ stdout: "", stderr: "", code: 0 }), - exitCode: answer.pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - stdout: Stream.fromEffect( - Deferred.succeed(started, undefined).pipe( - Effect.andThen(answer), - Effect.as(encoder.encode("1.0.0\n")), - ), - ), - }), - ); - }), - ), - ), - Effect.forkChild, - ); - yield* Deferred.await(started); - yield* TestClock.adjust(elapsed); - - const status = yield* Fiber.join(statusFiber); - assert.strictEqual(status.status, expectedStatus); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, version); - if (expectedStatus === "error") { - assert.include(status.message, "Timed out while running command."); - } - }), + "muse", + "opencode", + "pi", + ]); + assert.strictEqual(cursorProvider?.enabled, false); + assert.strictEqual(cursorProvider?.status, "disabled"); + assert.strictEqual(cursorProvider?.message, "Cursor is disabled in T3 Code settings."); + const museProvider = providers.find((provider) => provider.driver === "muse"); + assert.strictEqual(museProvider?.enabled, false); + assert.strictEqual(museProvider?.status, "disabled"); + assert.strictEqual(cursorSpawned, false); + }).pipe(Effect.provide(runtimeServices)); + }), ); + }); + describe("checkClaudeProviderStatus", () => { it.effect("returns ready and labels Bedrock-backed Claude as authenticated", () => Effect.gen(function* () { // Bedrock authenticates via external AWS credentials, so the SDK init @@ -4383,7 +2455,9 @@ it.layer( ), ), ); + }); + describe("checkClaudeProviderStatus", () => { it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { // The capability probe resolves for a logged-out CLI, so `tokenSource: @@ -4509,27 +2583,6 @@ it.layer( ), ); - it.effect("keeps a third-party backend authenticated without any token source", () => - Effect.gen(function* () { - // Bedrock and Vertex authenticate outside the CLI, so the account - // payload is empty by design rather than because nobody logged in. - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ tokenSource: "none", apiProvider: "bedrock" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("keeps a CLI that says nothing about its account authenticated", () => Effect.gen(function* () { // Profile-authenticated installs report no token source at all, and a @@ -4553,254 +2606,6 @@ it.layer( ), ); - it.effect("returns a display label for claude subscription types", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ subscriptionType: "maxplan" }), - ); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("reads banked resets only for subscription logins", () => - Effect.gen(function* () { - const check = (overrides: Partial) => - checkClaudeProviderStatus( - defaultClaudeSettings, - () => - Effect.succeed({ - email: undefined, - subscriptionType: undefined, - tokenSource: undefined, - apiKeySource: undefined, - apiProvider: undefined, - slashCommands: [], - usage: { rate_limits_available: true, rate_limits: {} }, - ...overrides, - }), - undefined, - undefined, - undefined, - undefined, - () => Effect.succeed({ availableCount: 2 }), - ); - const subscription = yield* check({ subscriptionType: "max" }); - const bedrock = yield* check({ apiProvider: "bedrock" }); - assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); - assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in full subscription labels", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max Subscription", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max Subscription"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("does not duplicate Claude in provider-prefixed subscription names", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "Claude Max", - }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.type, "Claude Max"); - assert.strictEqual(status.auth.label, "Claude Max Subscription"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns claude auth email from initialization result", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), - ); - assert.strictEqual(status.auth.status, "authenticated"); - assert.strictEqual(status.auth.email, "claude@example.com"); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: - '{"loggedIn":true,"authMethod":"claude.ai","account":{"email":"claude@example.com"}}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { - const claudeConfigDir = "/tmp/t3code-claude-home"; - const recorded = recordingMockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }); - - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - { - ...defaultClaudeSettings, - homePath: claudeConfigDir, - }, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), - [(yield* Path.Path).resolve(claudeConfigDir)], - ); - }).pipe(Effect.provide(recorded.layer)); - }); - - it.effect("includes probed claude slash commands in the provider snapshot", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands.slice(1), [ - { - name: "review", - description: "Review a pull request", - input: { hint: "pr-or-branch" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("deduplicates probed claude slash commands by name", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities({ - subscriptionType: "maxplan", - slashCommands: [ - { - name: "ui", - description: "Explore and refine UI", - }, - { - name: "ui", - input: { hint: "component-or-screen" }, - }, - ], - }), - ); - - assert.deepStrictEqual(status.slashCommands, [ - COMPACT_SLASH_COMMAND, - { - name: "ui", - description: "Explore and refine UI", - input: { hint: "component-or-screen" }, - }, - ]); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("reports an environment-token source as external", () => Effect.gen(function* () { // The SDK names the env variable as the token source. `claude @@ -4935,85 +2740,5 @@ it.layer( ), ), ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, false); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); - }).pipe(Effect.provide(layerFailingSpawner("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => { - const secretStderr = "Something went wrong: secret-token-value"; - return Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); - assert.ok(!(status.message ?? "").includes(secretStderr)); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { - stdout: "", - stderr: secretStderr, - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ); - }); - - it.effect("returns warning when the Claude initialization result is unavailable", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, - ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", - ); - - const timedOut = yield* checkClaudeProviderStatus(defaultClaudeSettings, () => - Effect.fail(new Cause.TimeoutError()), - ); - assert.strictEqual(timedOut.status, "warning"); - assert.strictEqual(timedOut.auth.status, "unknown"); - assert.strictEqual( - timedOut.message, - "Timed out after 25s while checking Claude account status.", - ); - }).pipe( - Effect.provide( - layerMockSpawner((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); }); }); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 39bc7129d45b..a327494ff207 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -321,30 +321,6 @@ export const mergeProviderSnapshot = ( }; }; -export const mergeProviderSnapshots = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): ReadonlyArray => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - - for (const provider of nextProviders) { - mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), - ); - } - - return orderProviderSnapshots([...mergedProviders.values()]); -}; - -export const selectProvidersByKind = ( - providers: ReadonlyArray, - providerKinds: ReadonlySet, -): ReadonlyArray => - providers.filter((provider) => providerKinds.has(provider.driver)); - const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 11fb306a904e..36fee6411d76 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -11,15 +11,12 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Result from "effect/Result"; -import * as TestClock from "effect/testing/TestClock"; import * as Stream from "effect/Stream"; import { describe, expect } from "vite-plus/test"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; import * as EffectAcpErrors from "effect-acp/errors"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -109,42 +106,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("awaits native resume instead of using the load replay idle fallback", () => - Effect.gen(function* () { - const resumeStarted = yield* Deferred.make(); - const requestMethods: Array = []; - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_WAIT_FOR_RESUME_RELEASE: "1", T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - resumeSessionId: "mock-session-1", - resumeMethod: "resume", - sessionLoadReplayIdleGap: "1 second", - requestLogger: (event) => - Effect.sync(() => { - if (event.status === "started") requestMethods.push(event.method); - }), - }); - yield* runtime.handleSessionUpdate((notification) => - notification.update.sessionUpdate === "user_message_chunk" - ? Deferred.succeed(resumeStarted, undefined).pipe(Effect.asVoid) - : Effect.void, - ); - const startup = yield* runtime.start().pipe(Effect.forkChild); - yield* Deferred.await(resumeStarted); - yield* TestClock.adjust("3 seconds"); - expect(startup.pollUnsafe()).toBeUndefined(); - yield* runtime.request("_test/release-resume", {}); - const started = yield* Fiber.join(startup); - - expect(started.sessionSetupResult._meta).toEqual({ nativeResume: true }); - expect(requestMethods).toContain("session/resume"); - expect(requestMethods).not.toContain("session/load"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("waits for native cancellation and drains final updates before another prompt", () => Effect.gen(function* () { const toolStarted = yield* Deferred.make(); @@ -230,61 +191,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("retires a process when native cancellation times out", () => - Effect.gen(function* () { - const toolStarted = yield* Deferred.make(); - const cancelReceived = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, - }, - cancelBehavior: "wait-for-prompt", - cancelTimeout: "1 second", - }); - yield* runtime.getEvents().pipe( - Stream.runForEach((event) => { - if (event._tag === "EventStreamBarrier") { - return Deferred.succeed(event.acknowledge, undefined); - } - if (event._tag === "ToolCallUpdated") { - return Deferred.succeed(toolStarted, undefined); - } - if (event._tag === "ThoughtDelta") { - return Deferred.succeed(cancelReceived, undefined); - } - return Effect.void; - }), - Effect.forkChild, - ); - yield* runtime.start(); - const prompt = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild); - yield* Deferred.await(toolStarted); - const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); - yield* Deferred.await(cancelReceived); - yield* TestClock.adjust("2 seconds"); - - const error = yield* Fiber.join(cancellation).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "AcpTransportError", - method: "session/cancel", - }); - expect(Exit.isFailure(yield* Fiber.await(prompt))).toBe(true); - expect( - yield* runtime - .prompt({ - prompt: [{ type: "text", text: "must not run" }], - }) - .pipe(Effect.flip), - ).toBe(error); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("reports an idle child exit and rejects later prompts", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); @@ -481,47 +387,6 @@ describe("AcpSessionRuntime", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("bounds native cancellation when its event consumer is absent", () => - Effect.gen(function* () { - const toolStarted = yield* Deferred.make(); - const cancelReceived = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - ...mockRuntimeOptions, - spawn: { - ...mockRuntimeOptions.spawn, - env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" }, - }, - cancelBehavior: "wait-for-prompt", - cancelTimeout: "1 second", - }); - yield* runtime.handleSessionUpdate((notification) => { - if (notification.update.sessionUpdate === "tool_call_update") { - return Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid); - } - if (notification.update.sessionUpdate === "agent_thought_chunk") { - return Deferred.succeed(cancelReceived, undefined).pipe(Effect.asVoid); - } - return Effect.void; - }); - yield* runtime.start(); - const prompt = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild); - yield* Deferred.await(toolStarted); - const cancellation = yield* runtime.cancel.pipe(Effect.forkChild); - yield* Deferred.await(cancelReceived); - yield* runtime.request("_test/finish-cancel", {}); - expect(yield* Fiber.join(prompt)).toMatchObject({ stopReason: "cancelled" }); - yield* TestClock.adjust("2 seconds"); - expect(yield* Fiber.join(cancellation).pipe(Effect.flip)).toMatchObject({ - _tag: "AcpTransportError", - method: "session/cancel", - }); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("does not restore ambient variables to a sanitized child environment", () => Effect.gen(function* () { yield* Effect.acquireRelease( @@ -564,49 +429,6 @@ describe("AcpSessionRuntime", () => { expect(AcpSessionRuntime.selectAcpAgentAuthMethod(methods, "missing")).toBeUndefined(); }); - it.effect("merges custom initialize client capabilities into the ACP handshake", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const initializeStarted = requestEvents.find( - (event) => event.method === "initialize" && event.status === "started", - ); - expect(initializeStarted?.payload).toMatchObject({ - protocolVersion: 2, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { parameterizedModelPicker: true }, - }, - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("prefers MCP-over-ACP only when the agent advertises that transport", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -643,41 +465,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("falls back to stdio MCP when the agent does not advertise MCP-over-ACP", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const created = requestEvents.find( - (event) => event.method === "session/new" && event.status === "started", - ); - expect(created?.payload).toMatchObject({ - mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }], - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - mcpServers: [{ type: "stdio", name: "t3-code", command: "/usr/bin/node", args: [] }], - acpMcpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }], - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("supports v2 session deletion and secret-safe provider management", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -730,37 +517,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("does not authenticate when an advertised method is not required", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - expect( - requestEvents.filter((event) => event.status === "started").map((event) => event.method), - ).toEqual(["initialize", "session/new"]); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { T3_ACP_AUTH_METHOD_ID: "test" }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("authenticates explicitly typed v2 agent methods and retries session creation", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -985,100 +741,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("supports successive standard ACP prompts", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const firstPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "first" }], - }); - const secondPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "second" }], - }); - - expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" }); - expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("releases a fully silent prompt and forwards configured cancel metadata", () => { - const protocolEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - const promptFiber = yield* runtime - .prompt({ - prompt: [{ type: "text", text: "hang forever" }], - }) - .pipe(Effect.forkChild({ startImmediately: true })); - - yield* TestClock.adjust("500 millis"); - yield* runtime.cancel; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - - expect( - protocolEvents.some( - (event) => - event.direction === "outgoing" && - event.stage === "raw" && - typeof event.payload === "string" && - event.payload.includes('"method":"session/cancel"') && - event.payload.includes('"cancelTrigger":"ctrl_c"'), - ), - ).toBe(true); - - const firstPromptResult = yield* Fiber.join(promptFiber); - expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" }); - - const secondPromptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "second" }], - }); - expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - cancelMeta: { cancelTrigger: "ctrl_c" }, - protocolLogging: { - logOutgoing: true, - logger: (event) => - Effect.sync(() => { - protocolEvents.push(event); - }), - }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1233,58 +895,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("logs ACP requests from the shared runtime", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setModel("composer-2"); - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "succeeded", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "succeeded", - ), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("supports negotiated ACP session list, fork, resume, and close methods", () => { const requestEvents: Array = []; return Effect.gen(function* () { @@ -1350,37 +960,6 @@ describe("AcpSessionRuntime", () => { ); }); - it.effect("lists sessions without creating a disposable session", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - const listed = yield* runtime.listSessions(); - - expect(listed.sessions).toHaveLength(1); - expect( - requestEvents.filter((event) => event.status === "started").map((event) => event.method), - ).toEqual(["initialize", "session/list"]); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { T3_ACP_SESSION_LIFECYCLE: "1" }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - it.effect("rejects unauthenticated ACP session lifecycle requests", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1430,88 +1009,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("skips no-op session config writes when the requested value is already active", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setConfigOption("model", "default"); - yield* runtime.setMode("ask"); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(false); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { - const protocolEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - protocolLogging: { - logIncoming: true, - logOutgoing: true, - logger: (event) => - Effect.sync(() => { - protocolEvents.push(event); - }), - }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - + // regression: #3156 harden ACP resume with replay-idle load readiness it.effect("fails session startup when session/load returns an error", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -1607,129 +1105,6 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("keeps active-session updates while loading a different session", () => - Effect.gen(function* () { - const loadStarted = yield* Deferred.make(); - const runtime = yield* AcpSessionRuntime.make({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "250", - }, - }, - cwd: process.cwd(), - sessionLoadTimeout: "2 seconds", - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - event.method === "session/load" && event.status === "started" - ? Deferred.succeed(loadStarted, undefined).pipe(Effect.asVoid) - : Effect.void, - }); - yield* runtime.start(); - - const eventsFiber = yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4)).pipe( - Effect.forkChild({ startImmediately: true }), - ); - const loadFiber = yield* runtime - .loadSession("mock-session-2") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(loadStarted); - - yield* runtime.prompt({ - prompt: [{ type: "text", text: "active session prompt" }], - }); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("2 seconds"))); - expect(events.map((event) => event._tag)).toEqual([ - "PlanUpdated", - "AssistantItemStarted", - "ContentDelta", - "AssistantItemCompleted", - ]); - expect( - events.some((event) => event._tag === "ContentDelta" && event.text === "hello from mock"), - ).toBe(true); - - const loaded = yield* Fiber.join(loadFiber).pipe(Effect.timeout("2 seconds")); - expect(loaded.sessionId).toBe("mock-session-2"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), TestClock.withLive), - ); - - it.effect("completes session/load after replay becomes idle while its RPC stays pending", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - const started = yield* runtime.start().pipe(Effect.timeout("2 seconds")); - - expect(started.sessionId).toBe("mock-session-1"); - expect(started.sessionSetupResult._meta).toMatchObject({ - t3SessionLoadReady: "replay_idle", - }); - - const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe( - Effect.timeoutOption("100 millis"), - ); - expect(Option.isNone(unexpectedReplayEvent)).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "10000", - }, - }, - cwd: process.cwd(), - resumeSessionId: "mock-session-1", - sessionLoadReplayIdleGap: "50 millis", - sessionLoadTimeout: "1 second", - clientInfo: { name: "t3-test", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - TestClock.withLive, - ), - ); - - it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; - yield* runtime.start(); - const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds")); - - expect(loaded.sessionId).toBe("mock-session-1"); - expect(loaded.sessionSetupResult._meta).toMatchObject({ - t3SessionLoadReady: "replay_idle", - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: mockAgentCommand, - args: mockAgentArgs, - env: { - T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", - T3_ACP_LOAD_SESSION_DELAY_MS: "10000", - }, - }, - cwd: process.cwd(), - sessionLoadReplayIdleGap: "50 millis", - sessionLoadTimeout: "1 second", - clientInfo: { name: "t3-test", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - TestClock.withLive, - ), - ); - it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index cc911f2c60d0..338588662935 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -2,21 +2,14 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; import { describe, expect, it, vi } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Scope from "effect/Scope"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; -const threadSpawnHelperSource = NodeURL.fileURLToPath( - new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), -); - const identity = ( pid: number, ppid: number, @@ -63,15 +56,6 @@ function makeController(input: { const server = () => identity(process.pid, 1, process.pid, process.pid, "server"); -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - describe("terminatePosixOwnedProcessTree", () => { it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { expect(AcpSessionRuntime.parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe( @@ -321,171 +305,6 @@ describe("terminatePosixOwnedProcessTree", () => { } }); - it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const controller = AcpSessionRuntime.makeLinuxCgroupController(); - const lease = controller.create(); - if (lease === undefined) return; - expect( - AcpSessionRuntime.parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")), - ).not.toBe(lease.relativePath); - const scratchRoot = NodePath.join(process.cwd(), "tmp"); - NodeFS.mkdirSync(scratchRoot, { recursive: true }); - const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); - const readyPath = NodePath.join(scratch, "ready"); - const childPath = NodePath.join(scratch, "child"); - const childProgram = "setInterval(() => {}, 1000);"; - const launcherProgram = [ - 'const fs = require("node:fs");', - 'const cp = require("node:child_process");', - "const child = cp.spawn(process.execPath, ['-e', process.argv[2]], { detached: true, stdio: 'ignore' });", - "fs.writeFileSync(process.argv[1], String(child.pid));", - "child.unref();", - ].join("\n"); - const providerProgram = [ - 'const fs = require("node:fs");', - 'const cp = require("node:child_process");', - "fs.writeFileSync(process.argv[1], String(process.pid));", - "process.on('SIGTERM', () => {", - " const launcher = cp.spawn(process.execPath, ['-e', process.argv[3], process.argv[2], process.argv[4]], { detached: true, stdio: 'ignore' });", - " launcher.unref();", - " process.exit(0);", - "});", - "setInterval(() => {}, 1000);", - ].join("\n"); - const wrapped = AcpSessionRuntime.wrapCommandForLinuxCgroup(lease, process.execPath, [ - "-e", - providerProgram, - readyPath, - childPath, - launcherProgram, - childProgram, - ]); - const provider = NodeChildProcess.spawn(wrapped.command, wrapped.args, { - detached: true, - env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", T3_ACP_CGROUP_WRAPPER: "1" }, - stdio: "ignore", - }); - provider.unref(); - const sentinel = NodeChildProcess.spawn(process.execPath, ["-e", childProgram], { - detached: true, - stdio: "ignore", - }); - sentinel.unref(); - let detachedChildPid: number | undefined; - try { - yield* Effect.gen(function* () { - while (!NodeFS.existsSync(readyPath)) yield* Effect.sleep("10 millis"); - }).pipe(Effect.timeout("2 seconds")); - process.kill(provider.pid!, "SIGTERM"); - detachedChildPid = yield* Effect.gen(function* () { - while (true) { - try { - const pid = Number(NodeFS.readFileSync(childPath, "utf8")); - if (Number.isSafeInteger(pid) && pid > 1) return pid; - } catch {} - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeout("2 seconds")); - expect(processExists(detachedChildPid)).toBe(true); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(processExists(sentinel.pid!)).toBe(true); - yield* Effect.gen(function* () { - while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); - }).pipe(Effect.timeout("2 seconds")); - expect(NodeFS.existsSync(lease.path)).toBe(false); - - const replacement = controller.create(); - expect(replacement?.path).not.toBe(lease.path); - if (replacement !== undefined) { - NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(replacement); - expect(NodeFS.existsSync(replacement.path)).toBe(false); - } - } finally { - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease).pipe(Effect.ignore); - try { - process.kill(-provider.pid!, "SIGKILL"); - } catch {} - try { - process.kill(-sentinel.pid!, "SIGKILL"); - } catch {} - if (detachedChildPid !== undefined && processExists(detachedChildPid)) { - try { - process.kill(detachedChildPid, "SIGKILL"); - } catch {} - } - NodeFS.rmSync(scratch, { recursive: true, force: true }); - } - }), - ); - - it.live("re-kills repopulated cgroups and verifies root removal after retryable errors", () => - Effect.gen(function* () { - let exists = true; - let killCalls = 0; - let populated = false; - let removeCalls = 0; - const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { - contains: () => false, - exists: () => exists, - path: "/test/t3-acp-repopulation", - relativePath: "/test/t3-acp-repopulation", - kill: () => { - killCalls += 1; - populated = false; - }, - populated: () => populated, - remove: () => { - removeCalls += 1; - if (removeCalls === 1) { - populated = true; - const error = new Error("repopulated") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; - } - if (removeCalls === 2) { - const error = new Error("nested path vanished") as NodeJS.ErrnoException; - error.code = "ENOENT"; - throw error; - } - exists = false; - }, - }; - - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(killCalls).toBeGreaterThanOrEqual(3); - expect(removeCalls).toBe(3); - expect(exists).toBe(false); - yield* AcpSessionRuntime.terminateLinuxCgroupLease(lease); - expect(removeCalls).toBe(3); - }), - ); - - it.live("reports persistent retryable removal failures as removal failures", () => - Effect.gen(function* () { - const removeError = Object.assign(new Error("cgroup remains busy"), { - code: "EBUSY", - }); - const lease: AcpSessionRuntime.AcpLinuxCgroupLease = { - contains: () => false, - exists: () => true, - path: "/test/t3-acp-busy", - relativePath: "/test/t3-acp-busy", - kill: () => undefined, - populated: () => false, - remove: () => { - throw removeError; - }, - }; - - const error = yield* Effect.flip(AcpSessionRuntime.terminateLinuxCgroupLease(lease)); - expect(error.detail).toBe("Failed to remove ACP cgroup /test/t3-acp-busy"); - expect(error.cause).toBe(removeError); - }), - ); - it("sends TERM only to the exact captured root while it remains in the child cgroup", () => { const fixture = makeController({ processes: [server(), identity(100, process.pid, 100, 100, "owned")], @@ -528,53 +347,6 @@ describe("terminatePosixOwnedProcessTree", () => { expect(migrated.signals).toEqual([]); }); - it.live("finds a child forked by a non-leader pthread", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) !== "linux") return; - const scratchRoot = NodePath.join(process.cwd(), "tmp"); - NodeFS.mkdirSync(scratchRoot, { recursive: true }); - const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-")); - const binary = NodePath.join(scratch, "helper"); - const pidPath = NodePath.join(scratch, "pids"); - expect(NodeFS.statSync(threadSpawnHelperSource).isFile()).toBe(true); - const compile = NodeChildProcess.spawnSync( - "cc", - ["-pthread", threadSpawnHelperSource, "-o", binary], - { - encoding: "utf8", - }, - ); - if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { - NodeFS.rmSync(scratch, { recursive: true, force: true }); - return; - } - expect(compile.status, compile.stderr).toBe(0); - const helper = NodeChildProcess.spawn(binary, [pidPath], { detached: true, stdio: "ignore" }); - helper.unref(); - try { - const published = yield* Effect.gen(function* () { - while (true) { - try { - const ids = NodeFS.readFileSync(pidPath, "utf8").trim().split(/\s+/).map(Number); - if (ids.length === 2 && ids.every(Number.isSafeInteger)) return ids; - } catch {} - yield* Effect.sleep("10 millis"); - } - }).pipe(Effect.timeout("2 seconds")); - const [workerTid, childPid] = published; - expect(workerTid).not.toBe(helper.pid); - expect( - AcpSessionRuntime.makePosixProcessTreeController("linux").childrenOf(helper.pid!), - ).toContainEqual(expect.objectContaining({ pid: childPid, ppid: helper.pid })); - } finally { - try { - process.kill(-helper.pid!, "SIGKILL"); - } catch {} - NodeFS.rmSync(scratch, { recursive: true, force: true }); - } - }), - ); - it.live("keeps ownership across a fork-before-exec executable transition", () => Effect.gen(function* () { const beforeExec = { ...identity(110, 100, 110, 110), executable: "/provider/fork" }; @@ -742,73 +514,6 @@ describe("terminatePosixOwnedProcessTree", () => { }), ); - it.live("polls only known descendants and stops with its scope", () => - Effect.gen(function* () { - let childrenCalls = 0; - let identityCalls = 0; - let snapshotCalls = 0; - const fixture = makeController({ - processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], - }); - const controller: AcpSessionRuntime.AcpPosixProcessTreeController = { - ...fixture.controller, - childPidsOf: (pid) => { - childrenCalls += 1; - return fixture.controller.childPidsOf(pid); - }, - identity: (pid) => { - identityCalls += 1; - return fixture.controller.identity(pid); - }, - snapshot: () => { - snapshotCalls += 1; - return fixture.controller.snapshot(); - }, - }; - const scope = yield* Scope.make(); - const frontier = new Map(); - const childQueues = new Map>(); - let ledgerValuesCalls = 0; - const ledger = new (class extends Map { - override values(): MapIterator { - ledgerValuesCalls += 1; - return super.values(); - } - })(); - const root: AcpSessionRuntime.AcpPosixOwnershipRoot = { value: undefined }; - for (let index = 0; index < 5_000; index += 1) { - const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); - ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { - ...tombstone, - parentExecutable: undefined, - parentStartTime: "", - }); - } - yield* AcpSessionRuntime.observePosixOwnershipLedgerContinuously({ - childQueues, - controller, - frontier, - ledger, - root, - rootPid: 100, - }).pipe(Effect.forkIn(scope)); - const timerStartedAt = yield* Clock.currentTimeMillis; - yield* Effect.sleep(0); - const timerDelay = (yield* Clock.currentTimeMillis) - timerStartedAt; - yield* Effect.sleep("80 millis"); - - expect(timerDelay).toBeLessThan(250); - expect(ledgerValuesCalls).toBe(0); - expect(snapshotCalls).toBe(0); - expect(childrenCalls).toBeGreaterThan(0); - expect(childrenCalls).toBeLessThanOrEqual(12); - const callsAtClose = identityCalls + childrenCalls; - yield* Scope.close(scope, Exit.void); - yield* Effect.sleep("60 millis"); - expect(identityCalls + childrenCalls).toBe(callsAtClose); - }), - ); - it.live("terminates nested owned groups bottom-up and catches a TERM fork race", () => Effect.gen(function* () { let forked = false; @@ -988,36 +693,6 @@ describe("terminatePosixOwnedProcessTree", () => { expect(admitted?.startTime).toBe("live-child"); }); - it("admits a live child when a stale ledger entry still occupies the reused PID", () => { - const staleChild: AcpSessionRuntime.AcpOwnedPosixProcess = { - ...identity(200, 100, 200, 200, "stale-child"), - parentExecutable: `/proc/100/exe`, - parentStartTime: "100", - }; - const liveChild = identity(200, 100, 200, 200, "live-child"); - const root = identity(100, process.pid, 100, 100); - const ownedRoot: AcpSessionRuntime.AcpOwnedPosixProcess = { - ...root, - parentExecutable: `/proc/${process.pid}/exe`, - parentStartTime: "server", - }; - const ledger = new Map([ - [`100:${root.startTime}`, ownedRoot], - [`200:${staleChild.startTime}`, staleChild], - ]); - - AcpSessionRuntime.capturePosixOwnershipLedger({ - controller: makeController({ processes: [server(), root, liveChild] }).controller, - ledger, - rootPid: 100, - table: [root, liveChild], - }); - - expect([...ledger.values()].some((entry) => entry.startTime === "stale-child")).toBe(false); - const admitted = [...ledger.values()].find((entry) => entry.pid === 200); - expect(admitted?.startTime).toBe("live-child"); - }); - it.live("signals children forked under a still-live parent during the same pass", () => Effect.gen(function* () { let forked = false; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 8bb03d3469cb..b36c4afe9159 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -4,7 +4,6 @@ import { ANTIGRAVITY_DEFAULT_MODEL, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, - type RuntimeMode, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -14,7 +13,6 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, } from "./AntigravityAcpSupport.ts"; @@ -60,22 +58,6 @@ describe("applyAntigravityAcpModelSelection", () => { }), ); - it.effect("reapplies an explicit selection even when setup reports the same model", () => - Effect.gen(function* () { - const { runtime, selections } = makeModelRuntime([ - { ...modelConfig, currentValue: "gemini-saved" }, - ]); - const model = yield* applyAntigravityAcpModelSelection({ - runtime, - model: "gemini-saved", - mapError: (cause) => cause, - }); - - expect(model).toBe("gemini-saved"); - expect(selections).toEqual(["gemini-saved"]); - }), - ); - it.effect.each([undefined, null, ANTIGRAVITY_DEFAULT_MODEL])( "uses the native default for %s without sending an internal model ID", (requestedModel) => @@ -154,34 +136,6 @@ describe("applyAntigravityAcpModelSelection", () => { expect(selections).toEqual(["gemini-saved"]); }), ); - - it.effect("reports a native model-selection failure through the adapter error mapper", () => - Effect.gen(function* () { - const nativeError = EffectAcpErrors.AcpRequestError.invalidParams("Model access changed."); - const { runtime } = makeModelRuntime([modelConfig], nativeError); - const error = yield* applyAntigravityAcpModelSelection({ - runtime, - model: "gemini-saved", - mapError: (cause) => ({ operation: "select-model", cause }), - }).pipe(Effect.flip); - - expect(error).toEqual({ operation: "select-model", cause: nativeError }); - }), - ); -}); - -describe("antigravityPermissionMode", () => { - it.each([ - { runtimeMode: "approval-required", nativeMode: "default" }, - { runtimeMode: "auto", nativeMode: "default" }, - { runtimeMode: "auto-accept-edits", nativeMode: "auto_edit" }, - { runtimeMode: "full-access", nativeMode: "yolo" }, - ] satisfies ReadonlyArray<{ runtimeMode: RuntimeMode; nativeMode: string }>)( - "maps $runtimeMode to $nativeMode", - ({ runtimeMode, nativeMode }) => { - expect(antigravityPermissionMode(runtimeMode)).toBe(nativeMode); - }, - ); }); const imageAttachment = { @@ -298,30 +252,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect("rejects missing folded clipboard text", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const pastedText = { - ...textAttachment, - name: "pasted-text.txt", - mimeType: "text/plain", - source: { _tag: "pasted-text" as const }, - } satisfies ChatAttachment; - - const error = yield* buildAntigravityPrompt({ - input: "Inspect the pasted text only as needed.", - attachments: [pastedText], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: "Could not read attachment 'pasted-text.txt'.", - }); - }), - ); - it.effect.each([ { name: "archive.zip", mimeType: "application/zip" }, { name: "clip.mp4", mimeType: "video/mp4" }, @@ -381,34 +311,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect("uses a PDF file resource link without copying or reading its bytes", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const upload = yield* fixture.write(pdfAttachment, "%PDF-1.7\n"); - const forbidFileAccess = () => - Effect.die("PDF prompt construction must only inspect the file."); - const prompt = yield* buildAntigravityPrompt({ - input: undefined, - attachments: [pdfAttachment], - attachmentsDir: fixture.attachmentsDir, - }).pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fixture.fs, - readFile: forbidFileAccess, - writeFile: forbidFileAccess, - writeFileString: forbidFileAccess, - copyFile: forbidFileAccess, - copy: forbidFileAccess, - rename: forbidFileAccess, - }), - ); - - expect(prompt).toEqual([ - { type: "resource_link", uri: upload.uri, name: "report.pdf", mimeType: "application/pdf" }, - ]); - }), - ); - it.effect.each([ { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, ] satisfies ReadonlyArray)( @@ -583,21 +485,4 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }); }), ); - - it.effect("rejects an empty turn", () => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const error = yield* buildAntigravityPrompt({ - input: " ", - attachments: [], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: "A turn requires text or supported attachments.", - }); - }), - ); }); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index cfc7a6adfbe1..20d084c4bf81 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -454,28 +454,6 @@ describe("Antigravity tool results", () => { expect(serialized).not.toContain("inline-data-url"); }); - it("keeps large assistant replies and startup command metadata unchanged", () => { - const notifications = [ - { - sessionId: "session-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "a".repeat(20_000) }, - }, - }, - { - sessionId: "session-1", - update: { - sessionUpdate: "available_commands_update", - availableCommands: [{ name: "plan", description: "Make a plan" }], - }, - }, - ] satisfies ReadonlyArray; - for (const notification of notifications) { - expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification); - } - }); - it("tracks only commands that have passed approval and are still running", () => { const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall); expect(isAntigravityOpenCommand(running)).toBe(true); diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 16a7df963374..9138a28dab5b 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -1,6 +1,3 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; - import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -15,20 +12,15 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER, ANTIGRAVITY_AUTH_STDOUT_PREFIX, - ANTIGRAVITY_PERSONAL_AUTH, ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, type AntigravityAuthConfig, - antigravityAuthConfigIssue, type AntigravityProfile, - antigravityProfileSettings, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, makeAntigravityStderrHandler, @@ -150,47 +142,6 @@ describe("Antigravity process environment", () => { expect(business.GOOGLE_API_KEY).toBeUndefined(); }); - it("writes the auth method and GCP block into the agent's settings.json", () => { - expect( - decodeJson( - antigravityProfileSettings({ - authMethod: "oauth-business", - apiKey: "never-written", - gcpProject: "proj", - gcpLocation: "us-central1", - }), - ), - ).toEqual({ - auth: { type: "oauth-business" }, - gcp: { project: "proj", location: "us-central1" }, - }); - expect(decodeJson(antigravityProfileSettings(ANTIGRAVITY_PERSONAL_AUTH))).toEqual({ - auth: { type: "oauth-personal" }, - }); - }); - - it("names the missing credential for each method", () => { - expect(antigravityAuthConfigIssue(ANTIGRAVITY_PERSONAL_AUTH)).toBeNull(); - expect( - antigravityAuthConfigIssue({ ...ANTIGRAVITY_PERSONAL_AUTH, authMethod: "gemini-api-key" }), - ).toContain("API key"); - expect( - antigravityAuthConfigIssue({ - ...ANTIGRAVITY_PERSONAL_AUTH, - authMethod: "oauth-business", - gcpProject: "proj", - }), - ).toContain("location"); - expect( - antigravityAuthConfigIssue({ - ...ANTIGRAVITY_PERSONAL_AUTH, - authMethod: "agent-platform", - gcpProject: "proj", - gcpLocation: "us-central1", - }), - ).toBeNull(); - }); - it("isolates TEMP and TMP to the profile directory on Windows", () => { const windowsProfile: AntigravityProfile = { platform: "win32", @@ -220,30 +171,6 @@ describe("Antigravity process environment", () => { expect(perRun.env?.TMP).toBe(`${windowsProfile.tempDirectory}\\run-1`); }); - it("isolates TMPDIR to the profile directory on POSIX hosts", () => { - const spawn = buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile, - cwd: "/project", - baseEnv: { TMPDIR: "/tmp" }, - runtimeTempDirectory: `${profile.tempDirectory}/run-1`, - }); - expect(spawn.env?.TMPDIR).toBe(`${profile.tempDirectory}/run-1`); - expect(spawn.env?.TEMP).toBeUndefined(); - }); - - it("uses the registry launch arguments for each supported host platform", () => { - for (const platform of ["linux", "darwin", "win32"] as const) { - const spawn = buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile: { ...profile, platform }, - cwd: "/project", - baseEnv: {}, - }); - expect(spawn.args).toEqual(platform === "linux" ? ["--uid="] : []); - } - }); - it.effect("keeps accounts separate even when instance IDs differ only by case", () => Effect.gen(function* () { const first = yield* resolveAntigravityInstanceDirectories( @@ -284,16 +211,6 @@ describe("Antigravity process environment", () => { }); describe("Antigravity authorization URL", () => { - it.effect("returns the official Google URL and its owned loopback target", () => - Effect.gen(function* () { - expect(yield* parseAntigravityAuthorizationUrl(authorizationUrl)).toEqual({ - authorizationUrl, - redirectUri: "http://127.0.0.1:46353/", - state: "test-opaque-state", - }); - }), - ); - it.effect( "rejects other origins, ambiguous state, and non-loopback redirects without retaining them", () => @@ -377,17 +294,6 @@ describe("Antigravity stdout compatibility", () => { }), ); - it.effect("handles an auth line without a final newline", () => - Effect.gen(function* () { - const urls: string[] = []; - const result = yield* makeAntigravityStdoutTransform({ - onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)), - })(Stream.make(encode(authLine.slice(0, -1)))).pipe(Stream.runCollect); - expect(result).toEqual([]); - expect(urls).toEqual([authorizationUrl]); - }), - ); - it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () => Effect.gen(function* () { const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe( @@ -484,22 +390,6 @@ describe("Antigravity stderr compatibility", () => { }), ); - it.effect("preserves failures from the sign-in flow owner", () => - Effect.gen(function* () { - const failure = new AcpErrors.AcpTransportError({ - detail: "The sign-in flow stopped.", - cause: undefined, - }); - const handleStderr = makeAntigravityStderrHandler({ - onAuthorizationUrl: () => Effect.fail(failure), - }); - const error = yield* handleStderr( - `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`, - ).pipe(Effect.flip); - expect(error).toBe(failure); - }), - ); - it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () => Effect.gen(function* () { const urls: string[] = []; @@ -622,16 +512,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); - caTest("leaves non-Linux certificate defaults unchanged even when a bundle exists", () => - Effect.gen(function* () { - const bundle = "/etc/ssl/certs/ca-certificates.crt"; - for (const platform of ["darwin", "win32"] as const) { - const env = yield* prepareSpawn([bundle], { NIX_SSL_CERT_FILE: bundle }, platform); - expect(env?.SSL_CERT_FILE).toBeUndefined(); - } - }), - ); - it.effect("runs the browser helper with installed Node in standalone builds", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -759,61 +639,6 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); - it.effect("keeps the browser helper successful when cancellation closes stderr", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - let helperCommand: ChildProcess.StandardCommand | undefined; - yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) helperCommand = command; - return spawner.spawn(command); - }), - ), - ); - expect(helperCommand).toBeDefined(); - if (!helperCommand) return; - const command = helperCommand; - const child = yield* Effect.acquireRelease( - Effect.sync(() => - NodeChildProcess.spawn(command.command, command.args, { - env: { ...command.options.env }, - stdio: ["ignore", "ignore", "pipe"], - }), - ), - (process) => Effect.sync(() => void process.kill()), - ); - child.stderr?.destroy(); - const exitCode = yield* Effect.promise( - () => - new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", resolve); - }), - ); - expect(exitCode).toBe(0); - }), - ); - - it.effect("fails before creating a profile when the helper cannot start", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profileDirectory = path.join(temporaryDirectory, "unused-profile"); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - runtimeExecutablePath: path.join(temporaryDirectory, "missing-runtime"), - }).pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - expect(yield* fs.exists(profileDirectory)).toBe(false); - }), - ); - it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index c32975dc7066..4e4d32ffc107 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -97,7 +97,7 @@ export interface AntigravityAuthConfig { readonly gcpLocation: string; } -export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { +const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = { authMethod: "oauth-personal", apiKey: "", gcpProject: "", @@ -142,7 +142,7 @@ export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string * `auth.type` names the selected method so a native logout clears only that method's credentials * instead of every stored token. Never holds a credential. */ -export function antigravityProfileSettings(auth: AntigravityAuthConfig): string { +function antigravityProfileSettings(auth: AntigravityAuthConfig): string { const gcp = { ...(auth.gcpProject ? { project: auth.gcpProject } : {}), ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}), diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 6fa7f65f8431..d494c4924d0e 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -284,21 +284,6 @@ describe("Antigravity subscription limits", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("preserves cached access token when quota endpoints return no usable windows", () => - Effect.gen(function* () { - const { probe, requests } = yield* fixture({ - summary: { groups: [] }, - models: { models: {} }, - }); - const first = yield* probe; - expect(first.unavailable?.reason).toBe("probeFailed"); - expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - const second = yield* probe; - expect(second.unavailable?.reason).toBe("probeFailed"); - expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("returns a safe failure when OAuth refresh fails", () => Effect.gen(function* () { const { probe } = yield* fixture({ tokenStatus: 401 }); diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index c946afb66832..8db751657454 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -1,13 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; @@ -209,41 +206,6 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { }), ); - it.effect("times out a stalled claim body", () => - Effect.gen(function* () { - const login = yield* writeLogin; - const readingBody = yield* Deferred.make(); - const client = HttpClient.make((request) => { - const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); - Object.defineProperty(response, "json", { - value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), - }); - return Effect.succeed(response); - }); - const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ - ...login, - version: "2.1.0", - grantId: "grant_a", - requestId: "r-1", - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HttpClient.HttpClient, client), - Effect.result, - Effect.forkChild, - ); - yield* Deferred.await(readingBody); - yield* TestClock.adjust("26 seconds"); - expect(yield* Fiber.join(claim)).toMatchObject({ - _tag: "Failure", - failure: { - _tag: "ClaudeResetCreditError", - reason: "requestFailed", - cause: { _tag: "TimeoutError" }, - }, - }); - }).pipe(Effect.provide(TestClock.layer())), - ); - it.effect("refuses malformed ids without sending anything", () => Effect.gen(function* () { for (const ids of [ diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index ac47781a8963..7fc0a63a605d 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -86,24 +86,6 @@ describe("claudeUsageResponseToLimits", () => { ).toEqual({ overageIncluded: "Fable" }); }); - it.each([ - { rateLimitsAvailable: false, reason: "unsupported" }, - { rateLimitsAvailable: true, reason: "probeFailed" }, - ])( - "reports null usage as $reason when availability is $rateLimitsAvailable", - ({ rateLimitsAvailable, reason }) => { - expect( - claudeUsageResponseToLimits({ - checkedAt, - response: { rate_limits_available: rateLimitsAvailable, rate_limits: null }, - }), - ).toEqual({ - limits: { checkedAt, windows: [], unavailable: { reason } }, - names: noNames, - }); - }, - ); - it("skips a window the endpoint reports without a utilization", () => { expect( claudeUsageResponseToLimits({ @@ -288,16 +270,4 @@ describe("claudeRateLimitEventToUpdate", () => { ], }); }); - - it("ignores windows the page does not render and events without a utilization", () => { - expect( - claudeRateLimitEventToUpdate( - { status: "allowed", rateLimitType: "seven_day_opus", utilization: 0.1 }, - noNames, - ), - ).toBeUndefined(); - expect( - claudeRateLimitEventToUpdate({ status: "rejected", rateLimitType: "five_hour" }, noNames), - ).toBeUndefined(); - }); }); diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index a7dfc81fae86..ce18c8f3f1bd 100644 --- a/apps/server/src/provider/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -2,31 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { - codexAppServerArgs, - codexExecLaunchArgs, - resolveCodexLaunchArgs, -} from "./codexLaunchArgs.ts"; - -describe("resolveCodexLaunchArgs", () => { - it("uses T3CODE_CODEX_LAUNCH_ARGS before configured settings", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: "--enable foo" }), - "--enable foo", - ); - }); - - it("uses configured settings when T3CODE_CODEX_LAUNCH_ARGS is empty", () => { - NodeAssert.equal( - resolveCodexLaunchArgs(" --strict-config ", { T3CODE_CODEX_LAUNCH_ARGS: " " }), - "--strict-config", - ); - }); - - it("ignores whitespace-only environment values", () => { - NodeAssert.equal(resolveCodexLaunchArgs("", { T3CODE_CODEX_LAUNCH_ARGS: " " }), ""); - }); -}); +import { codexAppServerArgs, codexExecLaunchArgs } from "./codexLaunchArgs.ts"; describe("codexAppServerArgs", () => { it("preserves escaped TOML quotes in config values", () => { @@ -35,29 +11,9 @@ describe("codexAppServerArgs", () => { ["app-server", "--config", 'model="gpt-6.1-sol"', "--strict-config"], ); }); - - it("returns the app-server command for empty launch args", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs(""), ["app-server"]); - }); - - it("appends parsed launch args after app-server", () => { - NodeAssert.deepStrictEqual(codexAppServerArgs("--strict-config --enable foo"), [ - "app-server", - "--strict-config", - "--enable", - "foo", - ]); - }); }); describe("codexExecLaunchArgs", () => { - it("preserves escaped TOML quotes in config values", () => { - NodeAssert.deepStrictEqual( - codexExecLaunchArgs(String.raw`--config model=\"gpt-6.1-sol\" --strict-config`), - ["--config", 'model="gpt-6.1-sol"', "--strict-config"], - ); - }); - it("keeps shared codex flags and omits app-server-only flags", () => { NodeAssert.deepStrictEqual( codexExecLaunchArgs('--strict-config --enable foo --listen off --config model="gpt 5"'), diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 0a1906685981..51970a3a5472 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -5,7 +5,6 @@ import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, codexRateLimitsToUpdate, - codexResetCreditsToContract, codexUsageLimitMessage, mergeCodexRateLimits, } from "./codexUsageLimits.ts"; @@ -127,19 +126,6 @@ describe("codexRateLimitsToLimits", () => { ); }, ); - - it("does not show a model-specific legacy snapshot as the main allowance", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { - limitId: "codex_bengalfox", - individualLimit: { remainingPercent: 99, resetsAt: 1_784_500_000 }, - secondary: { usedPercent: 90 }, - }, - }).windows, - ).toEqual([]); - }); }); describe("codexRateLimitsToUpdate", () => { @@ -200,39 +186,6 @@ describe("codexRateLimitsFailureMessage", () => { ), ).toBe("Codex could not read usage (JSON-RPC -32603)."); }); - - it("phrases a dead process differently from a bad answer", () => { - expect( - codexRateLimitsFailureMessage(new CodexErrors.CodexAppServerProcessExitedError({ code: 1 })), - ).toBe("Codex exited before it could report usage."); - }); -}); - -describe("codexResetCreditsToContract", () => { - it("counts available credits and reports the soonest expiry", () => { - expect( - codexResetCreditsToContract({ - availableCount: 2, - credits: [ - { status: "available", expiresAt: 1_784_500_000 }, - { status: "redeemed", expiresAt: 1_700_000_000 }, - { status: "available", expiresAt: 1_784_000_000 }, - ], - }), - ).toEqual({ availableCount: 2, nextExpiresAt: "2026-07-14T03:33:20.000Z" }); - expect(codexResetCreditsToContract({ availableCount: 0 })).toEqual({ availableCount: 0 }); - expect(codexResetCreditsToContract(null)).toBeUndefined(); - }); - - it("rides along on the probe's limits", () => { - expect( - codexRateLimitsToLimits({ - checkedAt, - snapshot: { primary: { usedPercent: 5, windowDurationMins: 300 } }, - resetCredits: { availableCount: 1 }, - }).resetCredits, - ).toEqual({ availableCount: 1 }); - }); }); describe("codexUsageLimitMessage", () => { @@ -277,23 +230,6 @@ describe("codexUsageLimitMessage", () => { "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", ); }); - - it("names no window when credits run out without one", () => { - expect( - codexUsageLimitMessage( - { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, - at, - ), - ).toBe( - "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", - ); - }); - - it("says only what it knows without a snapshot", () => { - expect(codexUsageLimitMessage(undefined, at)).toBe( - "Codex usage limit reached. Send the message again once the limit resets.", - ); - }); }); describe("mergeCodexRateLimits", () => { @@ -332,24 +268,6 @@ describe("mergeCodexRateLimits", () => { expect(codexRateLimitsToLimits({ checkedAt, snapshot: cleared! }).windows).toEqual([]); }); - it("keeps windows an update does not carry", () => { - const merged = mergeCodexRateLimits( - { - limitId: "codex", - planType: "business", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }, - { rateLimitReachedType: "rate_limit_reached" }, - ); - - expect(merged).toEqual({ - limitId: "codex", - planType: "business", - rateLimitReachedType: "rate_limit_reached", - primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, - }); - }); - it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { const main = { limitId: "codex", diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index f3ec514aae31..0baae05dd816 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -102,7 +102,7 @@ function codexRateLimitsToWindows( return windows; } -export function codexResetCreditsToContract( +function codexResetCreditsToContract( summary: CodexResetCreditsSummary | null | undefined, ): ServerProviderResetCredits | undefined { if (!summary) return undefined; diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index d440e69b42ec..03ff1f4439bf 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -110,6 +110,9 @@ describe("provider compatibility", () => { }); it("bundles a compatibility policy for every built-in harness", () => { + const driverKinds = BUILT_IN_DRIVERS.map((builtIn) => builtIn.driverKind); + assert.ok(driverKinds.some((kind) => kind === "devin")); + assert.ok(driverKinds.some((kind) => kind === "customAcp")); for (const builtIn of BUILT_IN_DRIVERS) { // Registry and custom ACP entries are arbitrary external agents, not one // versioned harness. @@ -298,6 +301,10 @@ describe("provider compatibility", () => { "grok", "opencode", "antigravity", + "devin", + "customAcp", + "pi", + "muse", "customDriver", ]) { const adapter = ProviderDriverKind.make(kind); diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index af72c11a19f0..32d417a1d43d 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -123,27 +123,6 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* }); describe("provider installation routing", () => { - it.effect("allows explicit installation while the provider is disabled", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.router.start({ instanceId }); - assert.deepEqual(harness.calls, ["start"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("rejects another driver and unknown instances before installation", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex")) }); - const wrongDriver = yield* Effect.flip(harness.router.start({ instanceId })); - const missing = yield* Effect.flip( - harness.router.start({ instanceId: ProviderInstanceId.make("missing") }), - ); - assert.equal(wrongDriver._tag, "ProviderSetupError"); - assert.equal(missing._tag, "ProviderSetupError"); - assert.deepEqual(harness.calls, []); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("keeps external installs manual without hiding shared install status", () => Effect.gen(function* () { const harness = yield* makeHarness({ @@ -166,51 +145,6 @@ describe("provider installation routing", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("routes a managed Codex instance through its own installer and refreshes removal", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex"); - const harness = yield* makeHarness({ - instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { setupMode: "managed" }, - }, - }, - }, - }); - assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); - yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); - const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId: codexId })); - assert.equal(Array.from(observed)[0]?.driver, "codex"); - yield* harness.router.remove({ instanceId: codexId }); - assert.deepEqual(harness.calls, ["codex-start", "codex-cancel", "codex-remove", "refresh"]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("keeps native Codex installation outside managed setup", () => - Effect.gen(function* () { - const codexId = ProviderInstanceId.make("codex"); - const harness = yield* makeHarness({ - instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { setupMode: "existing" }, - }, - }, - }, - }); - assert.include( - (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, - "Choose managed setup", - ); - assert.deepEqual(harness.calls, []); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("protects another instance's binary found through its own PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -238,25 +172,4 @@ describe("provider installation routing", () => { assert.deepEqual(harness.calls, ["remove", "refresh"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - - it.effect("keeps only the configured path when another instance's binary is missing", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" }); - const binaryPath = path.join(directory, "missing"); - const harness = yield* makeHarness({ - settings: { - providerInstances: { - [ProviderInstanceId.make("antigravity-work")]: { - driver, - config: { binaryPath }, - }, - }, - }, - }); - yield* harness.router.remove({ instanceId }); - assert.deepEqual(harness.protectedPaths(), [binaryPath]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 21fcdf066435..de373771345b 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,8 +7,6 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -17,7 +15,6 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -284,31 +281,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("reports unchanged when the updater exits 0 but the provider is gone", () => { - return Effect.gen(function* () { - const { registry, providersRef } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - refreshAfterUpdate: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, installed: false, version: null })), - ), - }); - - const result = yield* updater.updateProvider(CODEX_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); - assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stdout: "updated" })), - ), - ), - ); - }); - it.effect( "keeps a successful update when the binary is present but its version is unreadable", () => { @@ -411,78 +383,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("reports the installer's latest output line while the update runs", () => { - const exited = Deferred.makeUnsafe(); - return Effect.gen(function* () { - const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); - const updater = yield* makeTestRunner(registry); - const runningMessages = Ref.get(updateStatesRef).pipe( - Effect.map((states) => - states.filter((state) => state.status === "running").map((state) => state.message), - ), - ); - - const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(1)); - assert.deepStrictEqual(yield* runningMessages, [ - "Checking for the latest version", - "Running native-agent update", - "80%", - ]); - - yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); - const result = yield* Fiber.join(fiber); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ - stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", - exitCode: Deferred.await(exited), - })), - ), - ), - ); - }); - - it.effect("installs the advertised version when pnpm owns the global install", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex", - updateExecutable: "pnpm", - updateArgs: ["add", "-g", "@openai/codex@latest"], - updateLockKey: "pnpm-global", - }), - ), - }); - - yield* updater.updateProvider(CODEX_DRIVER); - assert.deepStrictEqual(calls, [ - { command: "pnpm", args: ["add", "-g", "@openai/codex@9.9.9"] }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("9.9.9"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - it("splits installer output into clean status lines", () => { assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { lines: ["Downloading"], @@ -545,117 +445,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("uses the resolved provider capabilities when choosing the update executable", () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry({ - ...baseProvider, - versionAdvisory: { - status: "behind_latest", - currentVersion: "2.0.14", - latestVersion: "2.1.123", - updateCommand: "bun i -g @anthropic-ai/claude-code@latest", - canUpdate: true, - checkedAt: "2026-04-30T12:00:00.000Z", - message: "Update available.", - }, - }); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: () => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider: CODEX_DRIVER, - packageName: "@openai/codex", - updateExecutable: "bun", - updateArgs: ["i", "-g", "@openai/codex@latest"], - updateLockKey: "bun-global", - }), - ), - }); - - yield* updater.updateProvider(CODEX_DRIVER); - assert.deepStrictEqual(calls, [ - { - command: "bun", - args: ["i", "-g", "@openai/codex@latest"], - }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect( - "runs update commands through Effect ChildProcess when no test runner is injected", - () => { - const calls: Array<{ command: string; args: ReadonlyArray }> = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const runner = yield* makeTestRunner(registry); - - const result = yield* runner.updateProvider(CODEX_DRIVER); - - assert.deepStrictEqual(calls, [ - { - command: "npm", - args: ["install", "-g", "@openai/codex@latest"], - }, - ]); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((command, args) => { - calls.push({ command, args }); - return { stdout: "updated" }; - }), - ), - ), - ); - }, - ); - - it.effect("does not report verified success when selected-instance discovery fails", () => - Effect.gen(function* () { - const { registry, providersRef } = yield* makeRegistry(baseNativeCliProvider); - const updater = yield* makeTestRunner({ - ...registry, - refreshAfterUpdate: () => - Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ - ...provider, - status: "error", - message: "Discovery failed.", - })), - ), - }); - const result = yield* updater.updateProvider(NATIVE_CLI_DRIVER); - assert.strictEqual(result.providers[0]?.status, "error"); - assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged"); - assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner(() => ({ stdout: "updated" })), - ), - ), - ), - ); - it.effect("records command failure output in provider update state", () => Effect.gen(function* () { const { registry } = yield* makeRegistry(); @@ -751,122 +540,6 @@ describe("providerMaintenanceRunner", () => { ); }); - it.effect("serializes different providers that share the same update lock key", () => { - const firstStartedLatch: { resolve: () => void } = { resolve: () => {} }; - const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} }; - const firstStarted = new Promise((resolve) => { - firstStartedLatch.resolve = resolve; - }); - const releaseFirst = new Promise((resolve) => { - releaseFirstLatch.resolve = resolve; - }); - const calls: Array = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex", - updateExecutable: "npm", - updateArgs: - provider === OPENCODE_DRIVER - ? ["install", "-g", "opencode-ai@latest"] - : ["install", "-g", "@openai/codex@latest"], - updateLockKey: "npm-global", - }), - ), - }); - - const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped); - yield* Effect.promise(() => firstStarted); - - const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped); - let providersWhileQueued: ReadonlyArray = []; - for (let attempt = 0; attempt < 20; attempt += 1) { - providersWhileQueued = yield* registry.getProviders; - const queuedStatus = providersWhileQueued.find( - (provider) => provider.instanceId === OPENCODE_INSTANCE_ID, - )?.updateState?.status; - if (queuedStatus === "queued") { - break; - } - yield* Effect.yieldNow; - } - assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); - assert.strictEqual( - providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID) - ?.updateState?.status, - "queued", - ); - - releaseFirstLatch.resolve(); - yield* Fiber.join(first); - yield* Fiber.join(second); - assert.deepStrictEqual(calls, [ - "install -g @openai/codex@latest", - "install -g opencode-ai@latest", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("2.0.0"), - layerMockSpawner((_command, args) => { - calls.push(args.join(" ")); - if (calls.length === 1) { - firstStartedLatch.resolve(); - return { - stdout: "updated", - exitCode: Effect.promise(() => releaseFirst).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }; - } - return { stdout: "updated" }; - }), - ), - ), - ); - }); - - it.effect("accepts arbitrary driver-provided update lock keys", () => { - const calls: Array = []; - return Effect.gen(function* () { - const { registry } = yield* makeRegistry(baseProvider); - const updater = yield* makeTestRunner({ - ...registry, - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeProviderMaintenanceCapabilities({ - provider, - packageName: "@openai/codex", - updateExecutable: "npm", - updateArgs: ["install", "-g", "@openai/codex@latest"], - updateLockKey: "unknown-lock-key", - }), - ), - }); - - const result = yield* updater.updateProvider(CODEX_DRIVER); - assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); - assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerNonWindowsPlatform, - layerLatestVersionHttpClient("0.0.0"), - layerMockSpawner((_command, args) => { - calls.push(args.join(" ")); - return { stdout: "updated" }; - }), - ), - ), - ); - }); - it.effect( "releases the running-provider marker when interrupted after queuing but before the lock run starts", () => diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 28fb186bf276..7e13e271d86b 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -7,23 +7,15 @@ import { } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Logger from "effect/Logger"; import { hydrateCachedProvider, isCachedProviderCatalogCompatible, isCachedProviderCorrelated, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, } from "./providerStatusCache.ts"; const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); const CODEX_DRIVER = ProviderDriverKind.make("codex"); -const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); -const OPENCODE_DRIVER = ProviderDriverKind.make("opencode"); const makeProvider = ( provider: ProviderDriverKind, @@ -44,84 +36,6 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { - it.effect("logs structural diagnostics without retaining invalid cache contents", () => { - const messages: Array = []; - const logger = Logger.make((options) => { - if (Array.isArray(options.message)) { - messages.push(...options.message); - } else { - messages.push(options.message); - } - }); - - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); - const cachePath = `${tempDir}/provider.json`; - const secretCacheValue = "secret-cache-value"; - yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); - - const result = yield* readProviderStatusCache(cachePath); - - assert.strictEqual(result, undefined); - const failure = messages.find( - (message): message is Record => - typeof message === "object" && message !== null && "path" in message, - ); - assert.exists(failure); - assert.strictEqual(failure.path, cachePath); - assert.strictEqual(typeof failure.errorTag, "string"); - assert.ok(!("cause" in failure)); - assert.ok(!("issues" in failure)); - assert.ok(!Object.values(failure).map(String).join("\n").includes(secretCacheValue)); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - }); - - it.effect("writes and reads provider status snapshots", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); - const codexProvider = makeProvider(CODEX_DRIVER); - const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { - status: "warning", - auth: { status: "unknown" }, - }); - const openCodeProvider = makeProvider(OPENCODE_DRIVER, { - status: "warning", - auth: { status: "unknown", type: "opencode" }, - }); - const codexPath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), - }); - const claudePath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("claudeAgent")), - }); - const openCodePath = yield* resolveProviderStatusCachePath({ - cacheDir: tempDir, - instanceId: defaultInstanceIdForDriver(ProviderDriverKind.make("opencode")), - }); - - yield* writeProviderStatusCache({ - filePath: codexPath, - provider: codexProvider, - }); - yield* writeProviderStatusCache({ - filePath: claudePath, - provider: claudeProvider, - }); - yield* writeProviderStatusCache({ - filePath: openCodePath, - provider: openCodeProvider, - }); - - assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); - assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); - assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); - }), - ); - it("hydrates cached provider status while preserving current settings-derived models", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs deleted file mode 100755 index f85dba5e6b5f..000000000000 --- a/apps/server/src/provider/testing/ClaudeCapabilitiesProbe.fixture.mjs +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -import * as NodeFS from "node:fs"; -import * as NodeReadline from "node:readline"; -const args = process.argv.slice(2); -const mcpConfigIndex = args.indexOf("--mcp-config"); -const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; -let mcpConfig; -if (rawMcpConfig) { - const contents = NodeFS.existsSync(rawMcpConfig) - ? NodeFS.readFileSync(rawMcpConfig, "utf8") - : rawMcpConfig; - try { - mcpConfig = JSON.parse(contents); - } catch { - mcpConfig = contents; - } -} -NodeFS.writeFileSync( - process.env.T3_PROBE_INVOCATION_PATH, - JSON.stringify({ - args, - cwd: process.cwd(), - connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, - mcpConfig, - }), -); -const lines = NodeReadline.createInterface({ input: process.stdin }); -lines.on("line", (line) => { - const message = JSON.parse(line); - if (message.type !== "control_request") return; - const reply = (response) => - process.stdout.write( - JSON.stringify({ - type: "control_response", - response: { subtype: "success", request_id: message.request_id, response }, - }) + "\n", - ); - if (message.request?.subtype === "initialize") { - reply({ - commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], - agents: [], - output_style: "default", - available_output_styles: ["default"], - models: [], - account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, - }); - } - if (message.request?.subtype === "get_usage") { - reply({ - session: {}, - subscription_type: "pro", - rate_limits_available: true, - rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, - behaviors: null, - }); - } -}); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. -const keepAlive = setInterval(() => {}, 1_000); -lines.on("close", () => { - clearInterval(keepAlive); - process.exit(0); -}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts deleted file mode 100644 index 4b8f3c38ac13..000000000000 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; - -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; -import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; -import { gitHubProviderFailure } from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; - -const cause = new Error("redacted provider failure"); - -it("classifies rate limits from every pull-request provider", () => { - assert.deepStrictEqual( - gitHubProviderFailure( - new GitHubRequestPolicy.GitHubApiRateLimitError({ host: "github.com", operation: "execute" }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - gitLabProviderFailure( - new GitLabCli.GitLabCliRateLimitError({ - operation: "execute", - command: "glab", - cwd: "/repo", - cause, - }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - azureDevOpsProviderFailure( - new AzureDevOpsCli.AzureDevOpsCliRateLimitError({ - operation: "execute", - command: "az", - cwd: "/repo", - argumentCount: 1, - cause, - }), - ), - { reason: "rate-limited" }, - ); - assert.deepStrictEqual( - bitbucketProviderFailure( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 429, - responseBodyLength: 0, - retryAt: 120_000, - }), - ), - { reason: "rate-limited", retryAt: 120_000 }, - ); -}); - -it("keeps GitHub's exact retry time", () => { - assert.deepStrictEqual( - gitHubProviderFailure( - new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: SourceControlProviderKind.make("github"), - host: "github.com", - retryAt: 1_786_802_400_000, - }), - ), - { reason: "rate-limited", retryAt: 1_786_802_400_000 }, - ); -}); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index c6d55b8a24fa..2d2fcfc1f05a 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -1,214 +1,17 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { PullRequestOperationError } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as Queue from "effect/Queue"; -import * as TestClock from "effect/testing/TestClock"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; -import * as ServerConfig from "../config.ts"; -import { ServerActivation } from "../serverActivation.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; const cacheLayer = (directory: string) => PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); -const ageFiles = (directory: string, names: ReadonlyArray, age: Duration.Duration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const mtime = DateTime.toDateUtc(DateTime.subtractDuration(yield* DateTime.now, age)); - for (const name of names) yield* fs.utimes(path.join(directory, name), mtime, mtime); - }); - it.layer(NodeServices.layer)("PR filesystem cache", (it) => { - it.effect("prunes entry files written before the max age and keeps fresh ones", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T00:00:00Z"))); - const cache = yield* cacheLayer(directory); - yield* cache.get("old", Effect.succeed("old"), ["pr"]); - yield* cache.invalidate("pr"); - const [stale, revisions] = (yield* fs.readDirectory(directory)).toSorted(); - assert.strictEqual(revisions, "revisions"); - yield* cache.get("fresh", Effect.succeed("fresh")); - const fresh = (yield* fs.readDirectory(directory)).find( - (name) => name !== stale && name !== revisions, - ); - const unrelated = "unrelated.json"; - yield* fs.writeFileString(`${directory}/${unrelated}`, "{}"); - const prefixed = "pr-v2-backup.json"; - yield* fs.writeFileString(`${directory}/${prefixed}`, "{}"); - const justExpired = Duration.sum( - PullRequestReadCache.ENTRY_FILE_MAX_AGE, - Duration.seconds(1), - ); - yield* ageFiles(directory, [stale!, revisions!, unrelated, prefixed], justExpired); - yield* ageFiles(directory, [fresh!], PullRequestReadCache.ENTRY_FILE_MAX_AGE); - - yield* PullRequestReadCache.pruneExpiredEntryFiles(directory); - - assert.deepStrictEqual( - (yield* fs.readDirectory(directory)).toSorted(), - [fresh!, revisions!, unrelated, prefixed].toSorted(), - ); - }), - ); - - it.effect("shares one scoped prune loop across cache layer consumers", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const activation = yield* Deferred.make(); - let scans = 0; - const observedFs = FileSystem.FileSystem.of({ - ...fs, - // An empty directory keeps every sweep synchronous under the test clock. - readDirectory: () => - Effect.sync(() => { - scans++; - return []; - }), - }); - const caches: Array = []; - const sharedLayer = PullRequestReadCache.layer.pipe( - Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFs)), - ); - const consumers = Array.from({ length: 6 }, () => - Layer.effectDiscard( - Effect.gen(function* () { - caches.push(yield* PullRequestReadCache.PullRequestReadCache); - }), - ).pipe(Layer.provide(sharedLayer)), - ); - const scanCounts: Array = []; - - yield* Effect.scoped( - Effect.gen(function* () { - yield* Layer.build(Layer.mergeAll(consumers[0]!, ...consumers.slice(1))); - assert.strictEqual(caches.length, 6); - assert.strictEqual(new Set(caches).size, 1); - scanCounts.push(scans); - yield* Deferred.succeed(activation, undefined); - yield* TestClock.adjust(Duration.zero); - scanCounts.push(scans); - yield* TestClock.adjust("59 minutes"); - scanCounts.push(scans); - yield* TestClock.adjust("1 minute"); - scanCounts.push(scans); - yield* TestClock.adjust("1 hour"); - scanCounts.push(scans); - }).pipe(Effect.provideService(ServerActivation, Deferred.await(activation))), - ); - yield* TestClock.adjust("2 hours"); - assert.deepStrictEqual([...scanCounts, scans], [0, 1, 1, 2, 3, 3]); - }).pipe( - Effect.scoped, - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pr-cache-shared-" })), - ), - ); - - it.effect("uses memory without starting cleanup when the cache directory is unavailable", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { providerStatusCacheDir } = yield* ServerConfig.ServerConfig; - yield* fs.remove(providerStatusCacheDir, { recursive: true }); - yield* fs.writeFileString(providerStatusCacheDir, "blocks the cache directory"); - let scans = 0; - const observedFs = FileSystem.FileSystem.of({ - ...fs, - readDirectory: () => - Effect.sync(() => { - scans++; - return []; - }), - }); - yield* Effect.gen(function* () { - const cache = yield* PullRequestReadCache.PullRequestReadCache; - let reads = 0; - const lookup = Effect.sync(() => String(++reads)); - assert.strictEqual(yield* cache.get("summary", lookup, ["pr"]), "1"); - assert.strictEqual(yield* cache.get("summary", lookup, ["pr"]), "1"); - yield* cache.invalidate("pr"); - assert.strictEqual(yield* cache.get("summary", lookup, ["pr"]), "2"); - yield* TestClock.adjust("1 hour"); - assert.strictEqual(scans, 0); - }).pipe( - Effect.provide( - PullRequestReadCache.layer.pipe( - Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFs)), - ), - ), - ); - }).pipe( - Effect.scoped, - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pr-cache-fallback-" })), - ), - ); - - it.effect("sweeps the cache directory every hour once the layer is built", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { providerStatusCacheDir } = yield* ServerConfig.ServerConfig; - const directory = `${providerStatusCacheDir}/pull-requests`; - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe("2026-10-01T00:00:00Z"))); - yield* (yield* cacheLayer(directory)).get("summary", Effect.succeed("cached")); - const [entry] = yield* fs.readDirectory(directory); - yield* ageFiles( - directory, - [entry!], - Duration.subtract(PullRequestReadCache.ENTRY_FILE_MAX_AGE, Duration.minutes(90)), - ); - const operations = yield* Queue.unbounded(); - const observedFs = FileSystem.FileSystem.of({ - ...fs, - stat: (path) => fs.stat(path).pipe(Effect.tap(() => Queue.offer(operations, "stat"))), - remove: (path, options) => - fs.remove(path, options).pipe(Effect.tap(() => Queue.offer(operations, "remove"))), - }); - yield* Layer.build( - PullRequestReadCache.layer.pipe( - Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFs)), - ), - ); - assert.strictEqual(yield* Queue.take(operations), "stat"); - yield* TestClock.adjust("1 hour"); - assert.strictEqual(yield* Queue.take(operations), "stat"); - assert.deepStrictEqual(yield* fs.readDirectory(directory), [entry]); - yield* TestClock.adjust("1 hour"); - assert.deepStrictEqual(yield* Queue.takeN(operations, 2), ["stat", "remove"]); - assert.deepStrictEqual(yield* fs.readDirectory(directory), []); - }).pipe( - Effect.scoped, - Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pr-cache-layer-" })), - ), - ); - - it.effect("reuses files after restart and respects the original expiry", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - let reads = 0; - const lookup = Effect.sync(() => String(++reads)); - const first = yield* cacheLayer(directory); - const key = "long/repository/key".repeat(100); - assert.strictEqual(yield* first.get(key, lookup), "1"); - yield* TestClock.adjust("59 seconds"); - const restarted = yield* cacheLayer(directory); - assert.strictEqual(yield* restarted.get(key, lookup), "1"); - yield* TestClock.adjust("1 second"); - assert.strictEqual(yield* restarted.get(key, lookup), "2"); - assert.strictEqual(reads, 2); - assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); - }), - ); - it.effect("clears in-flight reads before a new service can reuse them", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -303,29 +106,6 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }).pipe(Effect.provide(KeyValueStore.layerMemory)), ); - it.effect("compacts expired scope records without discarding fresh PR data", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); - const cache = yield* cacheLayer(directory); - yield* cache.get("summary", Effect.succeed("old"), ["pr"]); - yield* cache.invalidate("pr"); - for (let index = 0; index < 100; index++) yield* cache.invalidate(`pr-${index}`); - assert.strictEqual((yield* fs.readDirectory(directory)).length, 2); - const before = (yield* fs.stat(`${directory}/revisions`)).size; - yield* TestClock.adjust("59 seconds"); - assert.strictEqual(yield* cache.get("summary", Effect.succeed("fresh"), ["pr"]), "fresh"); - yield* TestClock.adjust("1 second"); - yield* cache.invalidate("other-pr"); - assert.isTrue((yield* fs.stat(`${directory}/revisions`)).size < before); - const restarted = yield* cacheLayer(directory); - assert.strictEqual( - yield* restarted.get("summary", Effect.die("cache miss"), ["pr"]), - "fresh", - ); - }), - ); - it.effect("does not persist failed GitHub reads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 62466e182731..8d9691fd79e3 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -32,7 +32,7 @@ const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); * A file untouched for a day is long expired; the day only leaves slack for * clock changes. */ -export const ENTRY_FILE_MAX_AGE = Duration.days(1); +const ENTRY_FILE_MAX_AGE = Duration.days(1); type ReadError = PullRequestOperationError | PullRequestUnavailableError; const revisionCodec = Schema.fromJsonString( Schema.Record( @@ -179,33 +179,33 @@ export const make = Effect.gen(function* () { * The persisted cache drops an expired entry only when it is read again, so * files for PRs nobody reopens would otherwise stay forever. */ -export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")( - function* (directory: string) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); - const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => - ENTRY_FILE_NAME.test(name), +const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpiredEntryFiles")(function* ( + directory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE); + const entries = (yield* fileSystem.readDirectory(directory)).filter((name) => + ENTRY_FILE_NAME.test(name), + ); + const [, failures] = yield* Effect.partition(entries, (name) => { + const entryPath = path.join(directory, name); + return fileSystem.stat(entryPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(entryPath) + : Effect.void, + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), ); - const [, failures] = yield* Effect.partition(entries, (name) => { - const entryPath = path.join(directory, name); - return fileSystem.stat(entryPath).pipe( - Effect.flatMap((info) => - Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) - ? fileSystem.remove(entryPath) - : Effect.void, - ), - Effect.catchReason("PlatformError", "NotFound", () => Effect.void), - ); + }); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some PR cache files", { + failed: failures.length, + cause: failures[0], }); - if (failures.length > 0) { - yield* Effect.logWarning("Failed to prune some PR cache files", { - failed: failures.length, - cause: failures[0], - }); - } - }, -); + } +}); export const layer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 66efa337ecd1..76f703123c27 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -7,7 +7,8 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; +import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { ProjectId, GitCommandError, @@ -16,7 +17,6 @@ import { type PullRequestStack, type PullRequestRef, } from "@t3tools/contracts"; -import type { ReviewGuideGenerationInput } from "@t3tools/provider-core/server/textGeneration"; import * as ServerConfig from "../config.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -224,18 +224,33 @@ const layerBase = Layer.mergeAll( type Repositories = Effect.Success; -const awaitRunFailure = ( - service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], - input: PullRequestRef, -) => - service.get(input).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.succeed(result.run.detail) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); +// A run forks into the service's scope, so a test learns it settled when the run's `write` span +// ends; the run records its outcome synchronously right after. +const watchRuns = Effect.gen(function* () { + const ended = yield* Queue.unbounded(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + if (options.name !== "PullRequestReviewGuideService.write") return span; + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + Queue.offerUnsafe(ended, undefined); + }; + return span; + }, + }); + return { + generate: ( + service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], + input: PullRequestRef, + ) => service.generate(input).pipe(Effect.withTracer(tracer)), + settled: ( + service: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"], + input: PullRequestRef, + ) => Queue.take(ended).pipe(Effect.andThen(service.get(input))), + }; +}); const startSingleBranchRun = (input: { readonly repositoryName: string; @@ -331,7 +346,8 @@ const startSingleBranchRun = (input: { PullRequestReviewGuideService.PullRequestReviewGuideService, ); const guideReference = { ...reference, repository: `acme/${input.repositoryName}` }; - yield* service.generate(guideReference); + const runs = yield* watchRuns; + yield* runs.generate(service, guideReference); return { repositories, reportedHead, @@ -340,146 +356,11 @@ const startSingleBranchRun = (input: { stored, fetchedRefs, observed, + settled: runs.settled(service, guideReference), }; }); -// Runs drive real git, which the test clock does not advance, so waits use real time. it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideService", (it) => { - it.effect.each([ - { - name: "a short head already fetched", - shortHash: true, - prepare: "fetch-feature", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: [], - }, - { - name: "a short head on its branch", - shortHash: true, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["refs/heads/feature"], - }, - { - name: "a short head whose branch moves during the fetch", - shortHash: true, - prepare: "none", - pushDuringFetch: true, - refuseCommitFetch: true, - expectedFetches: ["refs/heads/feature"], - }, - { - name: "a full head served by commit", - shortHash: false, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: false, - expectedFetches: [""], - }, - { - name: "a full head on its branch", - shortHash: false, - prepare: "none", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["", "refs/heads/feature"], - }, - { - name: "a full head only on its pull ref", - shortHash: false, - prepare: "refs/pull/7/head", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: ["", "refs/heads/feature", "refs/pull/7/head"], - }, - { - name: "a full head only on its merge request ref", - shortHash: false, - prepare: "refs/merge-requests/7/head", - pushDuringFetch: false, - refuseCommitFetch: true, - expectedFetches: [ - "", - "refs/heads/feature", - "refs/pull/7/head", - "refs/merge-requests/7/head", - ], - }, - ])( - "writes a guide for the reported commit: $name", - ({ shortHash, prepare, pushDuringFetch, refuseCommitFetch, expectedFetches }) => - Effect.scoped( - Effect.gen(function* () { - const run = yield* startSingleBranchRun({ - repositoryName: "fetched-head", - reportedHead: (repositories) => - shortHash ? repositories.headSha.slice(0, 12) : repositories.headSha, - refuseCommitFetch, - prepare: (repositories, workspace) => { - const remote = `${repositories.root}/remote`; - if (prepare === "fetch-feature") { - return git(workspace, ["fetch", "--quiet", "origin", "feature"]).pipe( - Effect.asVoid, - ); - } - if (prepare === "none") return Effect.void; - return git(remote, ["update-ref", prepare, repositories.headSha]).pipe( - Effect.andThen( - git(remote, ["update-ref", "refs/heads/feature", repositories.lowerSha]), - ), - Effect.asVoid, - ); - }, - onBranchFetch: pushDuringFetch - ? (repositories) => { - const remote = `${repositories.root}/remote`; - return git(remote, ["checkout", "--quiet", "feature"]).pipe( - Effect.andThen( - git(remote, [ - "commit", - "--quiet", - "--allow-empty", - "-m", - "push during fetch", - ]), - ), - Effect.asVoid, - ); - } - : undefined, - }); - yield* Effect.raceFirst( - Deferred.await(run.stored), - awaitRunFailure(run.service, run.reference).pipe( - Effect.flatMap((detail) => Effect.die(`run failed: ${detail}`)), - ), - ); - const { headSha } = run.repositories; - const result = yield* run.service.get(run.reference); - assert.strictEqual(result.guide?.headSha, run.reportedHead); - assert.deepStrictEqual(result.guide?.stops[0]?.files, [{ path: "feature.txt" }]); - assert.deepStrictEqual(run.observed, [ - { cwd: run.observed[0]?.cwd, head: headSha, changed: "feature.txt" }, - ]); - const headRefs = new Set([ - headSha, - run.reportedHead, - "refs/heads/feature", - "refs/pull/7/head", - "refs/merge-requests/7/head", - ]); - assert.deepStrictEqual( - run.fetchedRefs.filter((ref) => headRefs.has(ref)), - expectedFetches.map((ref) => (ref === "" ? headSha : ref)), - ); - const fs = yield* FileSystem.FileSystem; - assert.isFalse(yield* fs.exists(run.observed[0]?.cwd ?? "")); - }), - ), - ); - it.effect.each([ { name: "a branch named like the short head", @@ -529,10 +410,10 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic Effect.asVoid, ), }); - const detail = yield* Effect.raceFirst( - awaitRunFailure(run.service, run.reference), - Deferred.await(run.stored).pipe(Effect.andThen(Effect.die("a guide was written"))), - ); + const result = yield* run.settled; + assert.isFalse(yield* Deferred.isDone(run.stored), "a guide was written"); + assert.strictEqual(result.run?._tag, "Failed"); + const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); @@ -542,124 +423,6 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); - it.effect("briefs the change against its own base in a checkout it then removes", () => - Effect.scoped( - Effect.gen(function* () { - const repositories = yield* makeRepositories; - const seen = yield* Deferred.make<{ - readonly input: ReviewGuideGenerationInput; - readonly changed: string; - readonly head: string; - readonly upper: string; - }>(); - const finish = yield* Deferred.make(); - const driver = yield* GitVcsDriver.GitVcsDriver; - const readCheckout = (cwd: string, args: ReadonlyArray) => - git(cwd, args).pipe( - Effect.provideService(GitVcsDriver.GitVcsDriver, driver), - Effect.orDie, - ); - const stored = yield* Deferred.make(); - - // Built in this test's scope: runs are forked into the service's scope and must outlive - // the call that obtains the service. - const context = yield* Layer.build( - makeLayer({ - ...repositories, - stored, - generate: (input) => - Effect.gen(function* () { - const changed = yield* readCheckout(input.cwd, [ - "diff", - "--name-only", - `${input.baseCommit}...HEAD`, - ]); - const head = yield* readCheckout(input.cwd, ["rev-parse", "HEAD"]); - const upper = yield* readCheckout(input.cwd, [ - "show", - `${repositories.upperSha}:upper.txt`, - ]); - yield* Deferred.succeed(seen, { input, changed, head, upper }); - yield* Deferred.await(finish); - return { - stops: [ - { - title: "The feature", - summary: "The change.", - attention: null, - excerpts: [ - { path: "feature.txt", side: "new", start: 4, end: 40, caption: "" }, - ], - files: [], - notes: [ - { path: "feature.txt", line: 5, title: "Kept", body: "Inside." }, - { path: "feature.txt", line: 30, title: "Dropped", body: "Outside." }, - ], - questions: [], - }, - ], - }; - }), - }), - ); - const service = Context.get( - context, - PullRequestReviewGuideService.PullRequestReviewGuideService, - ); - - const started = yield* service.generate(reference); - assert.strictEqual(started.run?._tag, "Running"); - assert.strictEqual(started.guide, null); - assert.isTrue(started.started); - - const failedRun = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - const observed = yield* Effect.raceFirst(Deferred.await(seen), failedRun); - assert.strictEqual(observed.head, repositories.headSha); - assert.strictEqual(observed.changed, "feature.txt"); - assert.strictEqual(observed.input.baseBranch, "stack-base"); - assert.deepStrictEqual(observed.input.stack, [ - { number: 6, title: "Lower", state: "open", headSha: repositories.lowerSha }, - { number: 7, title: "Add the feature", state: "open", headSha: repositories.headSha }, - { number: 8, title: "Upper", state: "open", headSha: repositories.upperSha }, - { number: 9, title: "Unpushed", state: "open", headSha: null }, - ]); - assert.strictEqual(observed.upper, "upper layer"); - - const joined = yield* service.generate(reference); - assert.strictEqual(joined.run, started.run); - assert.isFalse(joined.started); - - yield* Deferred.succeed(finish, undefined); - yield* Deferred.await(stored); - const finished = yield* service.get(reference); - assert.strictEqual(finished.guide?.headSha, repositories.headSha); - const guide = finished.guide; - assert.isNotNull(guide); - const [story] = guide.stops; - assert.deepStrictEqual(story?.excerpts, [ - { path: "feature.txt", side: "new", start: 4, end: 10, caption: "" }, - ]); - assert.deepStrictEqual( - story?.notes.map((note) => note.title), - ["Kept"], - ); - assert.strictEqual(guide.stops.length, 1); - - const fs = yield* FileSystem.FileSystem; - assert.isFalse(yield* fs.exists(observed.input.cwd)); - const worktrees = yield* git(repositories.workspace, ["worktree", "list", "--porcelain"]); - assert.notInclude(worktrees, observed.input.cwd); - }), - ), - ); - it.effect("measures only the PR change using the renamed verified remote's fetch URL", () => Effect.scoped( Effect.gen(function* () { @@ -756,119 +519,6 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic ), ); - it.effect("refuses to write a guide while the server has review guides off", () => - Effect.scoped( - Effect.gen(function* () { - const repositories = yield* makeRepositories; - const stored = yield* Deferred.make(); - const context = yield* Layer.build( - makeLayer({ - ...repositories, - stored, - enabled: false, - generate: () => Effect.die("a guide must not be written while the beta is off"), - }), - ); - const service = Context.get( - context, - PullRequestReviewGuideService.PullRequestReviewGuideService, - ); - const result = yield* Effect.flip(service.generate(reference)); - assert.include(result.message, "Review guides are off"); - }), - ), - ); - - it.effect( - "keeps a newer run's guide when an older run for the same pull request finishes late", - () => - Effect.scoped( - Effect.gen(function* () { - const repositories = yield* makeRepositories; - const stored = yield* Deferred.make(); - const currentHead = yield* Ref.make(repositories.headSha); - const releaseOld = yield* Deferred.make(); - const oldStarted = yield* Deferred.make(); - const answer = (title: string) => ({ - stops: [ - { - title, - summary: "", - attention: null, - excerpts: [], - files: [], - notes: [], - questions: [], - }, - ], - }); - const context = yield* Layer.build( - makeLayer({ - ...repositories, - stored, - currentHead, - generate: (input) => - input.cwd.length > 0 && input.baseCommit.length > 0 - ? Ref.get(currentHead).pipe( - Effect.flatMap((head) => - head === repositories.headSha - ? Deferred.succeed(oldStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseOld)), - Effect.as(answer("old head")), - ) - : Effect.succeed(answer("new head")), - ), - ) - : Effect.die("unreachable"), - }), - ); - const service = Context.get( - context, - PullRequestReviewGuideService.PullRequestReviewGuideService, - ); - - yield* service.generate(reference); - const firstFailed = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`first run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - yield* Effect.raceFirst(Deferred.await(oldStarted), firstFailed); - yield* Ref.set(currentHead, repositories.upperSha); - const second = yield* service.generate(reference); - assert.strictEqual(second.run?._tag, "Running"); - assert.strictEqual(second.run?.headSha, repositories.upperSha); - const failedRun = service.get(reference).pipe( - Effect.flatMap((result) => - result.run?._tag === "Failed" - ? Effect.die(`run failed: ${result.run.detail}`) - : Effect.fail("still running" as const), - ), - Effect.retry(Schedule.spaced("20 millis")), - ); - yield* Effect.raceFirst(Deferred.await(stored), failedRun); - - yield* Deferred.succeed(releaseOld, undefined); - const settled = yield* service.get(reference).pipe( - Effect.filterOrFail( - (result) => result.run === null, - () => "still running" as const, - ), - Effect.retry({ schedule: Schedule.spaced("20 millis"), times: 250 }), - ); - assert.strictEqual(settled.guide?.headSha, repositories.upperSha); - yield* Effect.sleep("200 millis"); - const after = yield* service.get(reference); - assert.isNull(after.run); - assert.strictEqual(after.guide?.headSha, repositories.upperSha); - assert.strictEqual(after.guide?.stops[0]?.title, "new head"); - }), - ), - ); - it.effect("refuses a pull request from a repository the project is not a checkout of", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 94be3aa70df5..8a389c96eeea 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,12 +1,9 @@ import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeV8 from "node:v8"; -import * as NodeVM from "node:vm"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -56,10 +53,6 @@ import { import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; -import { - FILE_REVISIONS_CACHE_CAPACITY, - MAX_FILE_REVISION_PATHS, -} from "./pullRequestViewedFiles.ts"; function project(input: { readonly id: string; @@ -137,237 +130,6 @@ function hostedChangeRequest(body: string, additions = 1) { }; } -it.effect("caches narrow previews and invalidates them after refresh or mutation", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => Effect.die("preview must not read the viewer"), - getChangeRequestPreview: () => - Effect.sync(() => { - reads += 1; - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const previews = yield* Effect.all([service.preview(ref), service.preview(ref)], { - concurrency: 2, - }); - assert.deepStrictEqual(previews[0], { - ...ref, - title: "Change request 1", - url: "https://host/pull/1", - author: { login: "octocat", name: null, avatarUrl: null }, - state: "open", - isDraft: false, - createdAt: "2026-07-01T00:00:00Z", - }); - assert.strictEqual(reads, 1); - yield* service.invalidate({ reference: ref }); - yield* service.preview(ref); - assert.strictEqual(reads, 2); - yield* service.runAction({ ...ref, action: "close" }); - yield* service.preview(ref); - assert.strictEqual(reads, 3); - yield* service.refreshAfterTurn(ref.projectId); - yield* service.preview(ref); - assert.strictEqual(reads, 4); - const error = yield* Effect.flip(service.preview({ ...ref, repository: "another/repo" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(reads, 4); - }), -); - -it.effect("keeps cached previews available and pauses uncached previews until quota resets", () => - Effect.gen(function* () { - let reads = 0; - const retryAt = Date.parse("2099-08-13T14:00:00Z"); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: ({ number }) => - Effect.gen(function* () { - reads++; - if (reads === 2) - return yield* new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestPreview", - reason: "rate-limited", - detail: "GitHub requests are paused until the rate limit resets.", - retryAt, - }); - return changeRequest(number, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.preview(ref); - const limited = yield* Effect.flip(service.preview({ ...ref, number: 2 })); - assert.instanceOf(limited, PullRequestOperationError); - if (limited._tag !== "PullRequestOperationError") return; - assert.include(limited.detail, "paused"); - assert.strictEqual((yield* service.preview(ref)).number, 1); - for (const number of [2, 3, 4]) { - const paused = yield* Effect.flip(service.preview({ ...ref, number })); - assert.instanceOf(paused, PullRequestOperationError); - if (paused._tag !== "PullRequestOperationError") return; - assert.include(paused.detail, "paused"); - } - assert.strictEqual(reads, 2); - yield* TestClock.setTime(retryAt); - assert.strictEqual((yield* service.preview({ ...ref, number: 2 })).number, 2); - assert.strictEqual(reads, 3); - }), -); - -it.effect("reuses only unexpired detail for previews", () => - Effect.gen(function* () { - let previewReads = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), - getChangeRequestPreview: () => - Effect.sync(() => { - previewReads++; - return { - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: "Updated title", - state: "closed" as const, - }; - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.detail(ref); - assert.strictEqual((yield* service.preview(ref)).title, "Change request 1"); - assert.strictEqual(previewReads, 0); - yield* TestClock.adjust("61 seconds"); - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Updated title"); - assert.strictEqual(preview.state, "closed"); - assert.strictEqual(previewReads, 1); - }), -); - -it.effect("shares detail and activity for a minute, and for ten once merged", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: - number === 1 - ? ("open" as const) - : number === 2 - ? ("merged" as const) - : ("closed" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const reread = Effect.forEach([1, 2, 3], (number) => { - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - const everything = [ - "activity #1", - "activity #2", - "activity #3", - "detail #1", - "detail #2", - "detail #3", - ]; - assert.deepStrictEqual(yield* reread, everything); - yield* TestClock.adjust("59 seconds"); - assert.deepStrictEqual(yield* reread, []); - yield* TestClock.adjust("2 seconds"); - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #3", "detail #1", "detail #3"]); - yield* TestClock.adjust("9 minutes"); - assert.deepStrictEqual(yield* reread, everything); - }), -); - -it.effect("re-reads an open pull request after a turn, but not a merged one", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: ({ number }) => - Effect.sync(() => { - reads.push(`detail #${number}`); - return { - ...hostedChangeRequest("Description"), - number, - state: number === 1 ? ("open" as const) : ("merged" as const), - }; - }), - getChangeRequestActivity: ({ number }) => - Effect.sync(() => { - reads.push(`activity #${number}`); - return { - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }; - }), - }), - ], - }); - const merged = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; - const reread = Effect.forEach([1, 2], (number) => { - const ref = { ...merged, number }; - return Effect.andThen(service.detail(ref), service.activity(ref)); - }).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), - ); - - assert.deepStrictEqual(yield* reread, ["activity #1", "activity #2", "detail #1", "detail #2"]); - for (let turn = 0; turn < 3; turn++) { - yield* service.refreshAfterTurn(merged.projectId); - assert.deepStrictEqual(yield* reread, ["activity #1", "detail #1"]); - } - yield* service.invalidate({ reference: merged }); - assert.deepStrictEqual(yield* reread, ["activity #2", "detail #2"]); - }), -); - it.effect("does not wait for an in-flight detail read to display a preview", () => Effect.gen(function* () { const detailStarted = yield* Deferred.make(); @@ -394,68 +156,6 @@ it.effect("does not wait for an in-flight detail read to display a preview", () }), ); -it.effect("keeps previews warm when another project finishes a turn", () => - Effect.gen(function* () { - const reads: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/d", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestPreview: (input) => - Effect.sync(() => { - reads.push(input.repository); - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - const web = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const docs = { projectId: "p2" as ProjectId, repository: "acme/docs", number: 1 }; - yield* service.preview(web); - yield* service.preview(docs); - yield* service.preview({ ...web, host: "github.com" }); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs"]); - yield* service.refreshAfterTurn(web.projectId); - yield* service.preview(web); - yield* service.preview(docs); - assert.deepStrictEqual(reads, ["acme/web", "acme/docs", "acme/web"]); - }), -); - -it.effect("uses full detail for hosts without a narrow preview", () => - Effect.gen(function* () { - let reads = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/w", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getChangeRequest: () => - Effect.sync(() => { - reads += 1; - return hostedChangeRequest("Description"); - }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const preview = yield* service.preview(ref); - assert.strictEqual(preview.title, "Change request 1"); - assert.strictEqual(reads, 1); - assert.ok(!("body" in preview)); - }), -); - function unusable(provider: SourceControlProviderKind, reason: "missing-tool" | "unauthenticated") { return new PullRequestProviderError({ provider, @@ -625,73 +325,6 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ); -it.effect("reports each host's actions with its summary, so rows can offer them unread", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/repo", - repository: "acme/web", - provider: SourceControlProviderKind.make("bitbucket"), - host: "bitbucket.org", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("bitbucket"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("bitbucket")).capabilities, - actions: ["merge", "close"], - }, - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(result.providers[0]?.actions, ["merge", "close"]); - }), -); - -it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => - Effect.gen(function* () { - let refinementCalls = 0; - const selfHosted = project({ - id: "p1", - title: "self-hosted", - workspaceRoot: "/gitlab", - repository: "group/project", - provider: SourceControlProviderKind.make("unknown"), - host: "code.example.test", - }); - const service = yield* makeService({ - projects: [ - selfHosted, - { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, - ], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - resolveHandle: ({ context }) => { - refinementCalls += 1; - assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); - return Effect.succeed({ - context: { - ...context!, - provider: { ...context!.provider, kind: SourceControlProviderKind.make("gitlab") }, - }, - provider: undefined as never, - }); - }, - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(refinementCalls, 1); - assert.strictEqual(result.providers[0]?.host, "code.example.test"); - assert.strictEqual(result.providers[0]?.kind, "gitlab"); - }), -); - it.effect("derives a legacy repository host after refining its provider", () => Effect.gen(function* () { const current = project({ @@ -817,30 +450,6 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ); -it.effect("asks for a whole page of a host, and for the reader's own size when given one", () => - Effect.gen(function* () { - const limits: number[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - limits.push(input.limit); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.list({ state: "open", limit: 10 }); - - assert.deepStrictEqual(limits, [99, 10]); - }), -); - it.effect("says where each repository carries on, and from nothing it has run out of", () => Effect.gen(function* () { const service = yield* makeService({ @@ -868,31 +477,6 @@ it.effect("says where each repository carries on, and from nothing it has run ou }), ); -it.effect("offers no continuation for a host that cannot be carried on from", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: true, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.isTrue(result.truncated); - assert.deepStrictEqual(result.nextCursors, {}); - }), -); - it.effect("uses a provider's raw cursor advance when it consumed malformed rows", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1003,64 +587,31 @@ it.effect("continues Bitbucket listings through tied timestamps and terminates", }), ); -it.effect("reads only the repositories it was asked to carry on with", () => +it.effect("keeps a row already sent at the boundary instant from arriving twice", () => Effect.gen(function* () { - const listed: string[] = []; - const cursors: Array = []; const service = yield* makeService({ projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - listed.push(input.repository); - cursors.push(input.cursor); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(7, "2026-07-02T00:00:00Z"), + changeRequest(8, "2026-07-02T00:00:00Z"), + changeRequest(9, "2026-07-01T00:00:00Z"), + ], + truncated: true, + continues: true, + }), }), ], }); const result = yield* service.list({ state: "open", - cursors: { "github.com acme/web": "2026-07-02T00:00:00Z|99|7" }, - }); - - assert.deepStrictEqual(listed, ["acme/web"]); - assert.deepStrictEqual(cursors, [ - { updatedBefore: "2026-07-02T00:00:00Z", delivered: 99, seenAt: [7] }, - ]); - assert.strictEqual(result.providers.length, 1); - }), -); - -it.effect("keeps a row already sent at the boundary instant from arriving twice", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - changeRequest(7, "2026-07-02T00:00:00Z"), - changeRequest(8, "2026-07-02T00:00:00Z"), - changeRequest(9, "2026-07-01T00:00:00Z"), - ], - truncated: true, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ - state: "open", - cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, + cursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|7" }, }); assert.deepStrictEqual( @@ -1109,31 +660,6 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega }), ); -it.effect("refuses a continuation it did not issue, before asking any host anything", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - ], - }); - - const error = yield* Effect.flip( - service.list({ state: "open", cursors: { "github.com pingdotgg/t3code": "yesterday" } }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual( - error.message, - "Pull request operation list failed: The list could not be carried on from where it left off.", - ); - }), -); - it.effect("calls a transient viewer failure a failed operation, not a signed-out CLI", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1200,90 +726,6 @@ it.effect("reports an unusable host over a merely failing one", () => }), ); -it.effect("lists every host that has an implementation", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/sub/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: (input) => - input.repository === "group/sub/project" - ? Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }) - : Effect.die("wrong repository identity"), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => [entry.provider, entry.number]), - [ - ["gitlab", 2], - ["github", 1], - ], - ); - }), -); - -it.effect("narrows the listing to one host when asked", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => Effect.die("should not be read"), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(2, "2026-07-05T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", host: "gitlab.com" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["gitlab"], - ); - }), -); - it.effect("tells two hosts of one kind apart in the switcher and the filter", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1326,75 +768,6 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ); -it.effect("keeps one host listed when another is not set up", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-01T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.provider), - ["github"], - ); - assert.deepStrictEqual( - result.providers.map((summary) => [summary.kind, summary.configured]), - [ - ["github", true], - ["gitlab", false], - ], - ); - }), -); - -it.effect("fails as unavailable only when no host can be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "missing-tool")), - }), - ], - }); - - const error = yield* service.list({ state: "open" }).pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual( - error._tag === "PullRequestUnavailableError" ? error.reason : null, - "cli-missing", - ); - }), -); - it.effect("reads a repository once when several worktrees share it", () => Effect.gen(function* () { let calls = 0; @@ -1963,300 +1336,76 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = }), ); -it.effect("hands the host the strategy an armed merge was asked for", () => +it.effect("keeps two hosts of one provider kind as two accounts", () => Effect.gen(function* () { - let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + project({ + id: "p2", + title: "enterprise", + workspaceRoot: "/enterprise", + repository: "acme/web", + host: "github.acme.dev", + }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => + getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), + listChangeRequests: () => Effect.succeed({ - actions: ["merge", "enable-auto-merge", "disable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, }), - runAction: (input) => { - ranWith = { - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }; - return Effect.void; - }, }), ], }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }); - assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" }); + const result = yield* service.list({ state: "open" }); - yield* service.runAction({ ...reference, action: "disable-auto-merge" }); - assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" }); + assert.strictEqual(result.entries.length, 2); + assert.deepStrictEqual(result.viewers, { + "github.com": "bilal", + "github.acme.dev": "b.hassan", + }); + assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ + "github.acme.dev", + "github.com", + ]); }), ); -it.effect("refuses an auto-merge the host never claimed, without asking it", () => +it.effect("stops new reads after a rate limit while leaving manual actions available", () => Effect.gen(function* () { - let ran = false; + let listCalls = 0; + let actionCalls = 0; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - runAction: () => { - ran = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "enable-auto-merge", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(ran); - }), -); - -it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "ready", "draft", "close", "reopen"], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }), - setThreadResolution: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setThreadResolution({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - threadId: "t1", - resolved: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "to resolve a review conversation."); - }), -); - -it.effect("asks nobody what the viewer may do when the host cannot do it at all", () => - Effect.gen(function* () { - let asked = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - }), - ], - }); - - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "reopen", - }), - ); - - assert.isFalse(asked); - }), -); - -it.effect("refuses a comment on a host that cannot post one", () => - Effect.gen(function* () { - let posted = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: false, - comment: false, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - comment: () => { - posted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.comment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - body: "Looks good.", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(posted); - }), -); - -it.effect("keeps two hosts of one provider kind as two accounts", () => - Effect.gen(function* () { - const viewerFor: Record = { "/cloud": "bilal", "/enterprise": "b.hassan" }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 2); - assert.deepStrictEqual(result.viewers, { - "github.com": "bilal", - "github.acme.dev": "b.hassan", - }); - assert.deepStrictEqual(result.entries.map((entry) => entry.host).toSorted(), [ - "github.acme.dev", - "github.com", - ]); - }), -); - -it.effect("reports repositories on a host that could not be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/enterprise", - repository: "acme/api", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => - input.cwd === "/cloud" - ? Effect.succeed("bilal") - : Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.strictEqual(result.entries.length, 1); - assert.deepStrictEqual( - result.errors.map((error) => error.projectId), - ["p2"], - ); - }), -); - -it.effect("stops new reads after a rate limit while leaving manual actions available", () => - Effect.gen(function* () { - let listCalls = 0; - let actionCalls = 0; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - }).pipe( - Effect.andThen( - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "listChangeRequests", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - ), - ), - runAction: () => - Effect.sync(() => { - actionCalls += 1; - }), + listChangeRequests: () => + Effect.sync(() => { + listCalls += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "listChangeRequests", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }), + ), + ), + ), + runAction: () => + Effect.sync(() => { + actionCalls += 1; + }), }), ], }); @@ -2277,272 +1426,33 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [providerName, host] of [ +for (const [] of [ ["github", "github.com"], ["gitlab", "gitlab.com"], ["forgejo", "code.example.test"], ["bitbucket", "bitbucket.org"], ["azure-devops", "dev.azure.com"], ] as const) { - const provider = SourceControlProviderKind.make(providerName); - it.effect.each(["detail", "checks"] as const)( - `shares fresh ${provider} %s reads and respects rate-limit resets`, - (read) => - Effect.gen(function* () { - let calls = 0; - let limited = false; - const repository = provider === "azure-devops" ? "web" : "acme/web"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository, provider, host }), - ], - providers: [ - fakeProvider(provider, { - [read === "detail" ? "getChangeRequest" : "getChangeRequestChecks"]: () => - Effect.gen(function* () { - calls += 1; - if (limited) { - return yield* new PullRequestProviderError({ - provider, - operation: "getChangeRequest", - reason: "rate-limited", - detail: "Retry after the reset.", - retryAt: (yield* Clock.currentTimeMillis) + 120_000, - }); - } - return hostedChangeRequest("current details"); - }), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository, - number: 1, - allowStale: false, - }; - const request: Effect.Effect = - service[read](reference); - yield* Effect.all([request, request], { concurrency: 2 }); - assert.strictEqual(calls, 1); - yield* TestClock.adjust("61 seconds"); - limited = true; - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("45 seconds"); - assert.strictEqual((yield* Effect.exit(request))._tag, "Failure"); - assert.strictEqual(calls, 2); - yield* TestClock.adjust("75 seconds"); - limited = false; - const recovered = yield* service[read](reference); - assert.strictEqual(recovered?.state, "open"); - assert.strictEqual(calls, 3); - yield* service.invalidate({ reference }); - yield* service[read](reference); - assert.strictEqual(calls, 4); - }), - ); } -it.effect("does not fall back to full detail when checks are unsupported", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/repo", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.die("Unexpected full detail read"), - }), - ], - }); - assert.isNull( - yield* service.checks({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - }), -); - -it.effect("uses a manual rate limit to pause later reads", () => +it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { - let listCalls = 0; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.sync(() => { - listCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "runAction", - reason: "rate-limited", - detail: "GitHub API rate limit exceeded.", - }), - ), - }), + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - yield* Effect.flip( - service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "close", - }), - ); - const error = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); + const error = yield* service + .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) + .pipe(Effect.flip); - assert.strictEqual(listCalls, 0); assert.strictEqual(error._tag, "PullRequestOperationError"); }), ); -it.effect("flags a review request for the viewer but not on their own change request", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewRequestLogins: ["Bilal"] }, - { - ...changeRequest(2, "2026-07-02T00:00:00Z"), - author: { login: "bilal", name: null, avatarUrl: null }, - reviewRequestLogins: ["bilal"], - }, - ], - truncated: false, - continues: true, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.viewerReviewRequested), - [true, false], - ); - }), -); - -it.effect("refuses a repository that does not belong to the requested project", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "attacker/repo", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("caches stack membership separately from action details", () => - Effect.gen(function* () { - const reads: Array = []; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: (input) => - Effect.sync(() => { - reads.push(input.includeDetails === true); - return { - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { - number: 7, - headBranch: "a", - state: "open" as const, - ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), - }, - ], - }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference, { includeDetails: false }); - const detail = yield* service.stack(reference); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true]); - assert.strictEqual(detail?.layers[0]?.headSha, "abc"); - yield* service.invalidate({ reference }); - yield* service.stack(reference, { includeDetails: false }); - yield* service.stack(reference); - assert.deepStrictEqual(reads, [false, true, false, true]); - }), -); - -it.effect("reads a host-native stack through the provider and null where it has none", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestStack: () => - Effect.succeed({ - id: "9", - number: 3, - url: "https://github.com/acme/web/stacks/3", - base: "main", - layers: [ - { number: 7, headBranch: "a", state: "open" as const }, - { number: 8, headBranch: "b", state: "open" as const }, - ], - }), - }), - ], - }); - - const stack = yield* service.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }); - assert.deepStrictEqual( - stack?.layers.map((layer) => layer.number), - [7, 8], - ); - - const withoutStacks = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - assert.isNull( - yield* withoutStacks.stack({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 7, - }), - ); - }), -); - -it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => +it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => Effect.gen(function* () { for (const provider of ["forgejo", "unknown"] as const) { const seen: string[] = []; @@ -2619,1370 +1529,368 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after projectId: "ssh" as ProjectId, host: "code.example:3000", repository: "team/repo", - number: 42, - }); - assert.strictEqual(preview.number, 42); - const detail = yield* service.detail({ - projectId: "ssh" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }); - assert.strictEqual(detail.body, "Forgejo detail"); - assert.deepStrictEqual(viewers, ["code.example:3000"]); - } - }), -); - -it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "http", - title: "http", - workspaceRoot: "/http", - repository: "team/repo", - provider: SourceControlProviderKind.make("forgejo"), - host: "code.example", - remoteUrl: "http://code.example:4000/team/repo.git", - }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], - }); - const failure = yield* service - .summary( - { - projectId: "http" as ProjectId, - host: "code.example:3000", - repository: "team/repo", - number: 42, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(failure._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("resolves a project's repository identity when its shell has none cached", () => - Effect.gen(function* () { - const resolved = project({ - id: "web", - title: "web", - workspaceRoot: "/web", - repository: "acme/web", - }); - const service = yield* makeService({ - projects: [{ ...resolved, repositoryIdentity: null }], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), - }), - ], - resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), - }); - - const summary = yield* service.summary( - { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - }), -); - -it.effect("routes a hosted reference to another repository through a project on that host", () => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - - const summary = yield* service.summary( - { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, - { recoverTransientFailure: false }, - ); - - assert.strictEqual(summary.number, 7); - assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); - }), -); - -it.effect("routes Azure reads and writes through the requested organization's checkout", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: ["org-a", "org-b"].map((organization) => - project({ - id: organization, - title: organization, - workspaceRoot: `/${organization}`, - repository: `${organization}/project/_git/web`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ), - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); - }), -); - -it.effect.each([ - { - host: "ssh.dev.azure.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", - }, - { - host: "vs-ssh.visualstudio.com", - repository: "v3/org-b/project/web", - remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", - }, - { - host: "org-b.visualstudio.com", - repository: "DefaultCollection/project/_git/web", - remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", - }, -])("routes Azure URL reads and writes through a $host checkout", (checkout) => - Effect.gen(function* () { - const seen: string[] = []; - const target = project({ - id: "target", - title: "target", - workspaceRoot: "/target", - provider: SourceControlProviderKind.make("azure-devops"), - ...checkout, - }); - const service = yield* makeService({ - projects: [ - ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => - project({ - id: repository, - title: repository, - workspaceRoot: `/${repository}`, - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - repository, - }), - ), - target, - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(`read ${input.cwd} ${input.repository}`); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - runAction: (input) => - Effect.sync(() => { - seen.push(`write ${input.cwd} ${input.repository}`); - }), - }), - ], - }); - const reference = { - projectId: "org-a/project/_git/web" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* service.runAction({ ...reference, action: "merge" }); - assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); - }), -); - -it.effect("refuses Azure cross-organization reads and writes without its checkout", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "org-a", - title: "org-a", - workspaceRoot: "/org-a", - repository: "org-a/project/_git/web", - provider: SourceControlProviderKind.make("azure-devops"), - host: "dev.azure.com", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), - runAction: () => Effect.die("must not modify the wrong organization"), - }), - ], - }); - const reference = { - projectId: "org-a" as ProjectId, - host: "dev.azure.com", - repository: "org-b/project/_git/web", - number: 7, - }; - const readError = yield* Effect.flip( - service.summary(reference, { recoverTransientFailure: false }), - ); - const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); - assert.strictEqual(readError._tag, "PullRequestUnavailableError"); - assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); - }), -); - -it.effect.each([ - ["github.com", "github"], - ["bitbucket.org", "bitbucket"], - ["codeberg.org", "forgejo"], -] as const)("reads linked PR summaries and stacks on %s without a checkout", ([host, kind]) => - Effect.gen(function* () { - const seen: Array<{ cwd: string; repository: string; host: string }> = []; - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [ - fakeProvider(SourceControlProviderKind.make(kind), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - getChangeRequestStack: (input) => - Effect.sync(() => { - seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); - return null; - }), - }), - ], - }); - const reference = { - projectId: "inbox" as ProjectId, - host, - repository: "acme/api", - number: 7, - }; - const summary = yield* service.summary(reference, { recoverTransientFailure: false }); - const stack = yield* service.stack(reference, { includeDetails: false }); - assert.strictEqual(summary.title, "Change request 7"); - assert.strictEqual(summary.state, "open"); - assert.strictEqual(summary.projectId, reference.projectId); - assert.strictEqual(stack, null); - assert.deepStrictEqual(seen, [ - { cwd: "/home/alex", repository: "acme/api", host }, - { cwd: "/home/alex", repository: "acme/api", host }, - ]); - }), -); - -it.effect("prefers a matching checkout over a non-repository project's directory", () => - Effect.gen(function* () { - const seen: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), - project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: (input) => - Effect.sync(() => { - seen.push(input.cwd); - return changeRequest(7, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - yield* service.summary( - { - projectId: "inbox" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ); - assert.deepStrictEqual(seen, ["/api"]); - }), -); - -it.effect("requires an explicit host for a non-repository project's linked PR", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a public host reference without a selected project directory", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - const error = yield* service - .summary( - { - projectId: "missing" as ProjectId, - host: "github.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect( - "requires a checkout for GitLab because its adapter does not explicitly target the host", - () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], - }); - const error = yield* service - .summary( - { - projectId: "inbox" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a hosted reference when its provider is unavailable", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), - ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], - }); - - const error = yield* service - .summary( - { - projectId: "frontend" as ProjectId, - host: "gitlab.com", - repository: "acme/api", - number: 7, - }, - { recoverTransientFailure: false }, - ) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestUnavailableError"); - }), -); - -it.effect("refuses a diff on a host that cannot produce one", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on azure", - workspaceRoot: "/a", - repository: "org/project", - provider: SourceControlProviderKind.make("azure-devops"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { - capabilities: { - diff: false, - comment: true, - actions: ["merge", "close"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .diff({ projectId: "p1" as ProjectId, repository: "org/project", number: 1 }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("rejects an empty comment before reaching the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - comment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* service - .comment({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - body: " ", - }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a verdict the host never claimed, without asking the provider", () => - Effect.gen(function* () { - let submitted = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { - inlineComment: true, - reply: true, - resolve: true, - verdicts: ["comment", "approve"], - }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => { - submitted = true; - return Effect.void; - }, - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "group/project", - number: 1, - verdict: "request-changes", - body: "no", - comments: [], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.isFalse(submitted); - }), -); - -it.effect("refuses line comments on a host that takes only a summary", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - submitReview: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.submitReview({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - verdict: "comment", - body: "", - comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect( - "refuses a review with neither a summary nor a comment, but lets an approval through", - () => - Effect.gen(function* () { - let approved = false; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "t3code", - workspaceRoot: "/a", - repository: "pingdotgg/t3code", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - submitReview: () => { - approved = true; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.submitReview({ ...reference, verdict: "comment", body: " ", comments: [] }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - - yield* service.submitReview({ ...reference, verdict: "approve", body: "", comments: [] }); - assert.isTrue(approved); - }), -); - -it.effect("refuses to resolve a conversation on a host that cannot", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, - reviewers: FULL_REVIEWERS, - }, - setThreadResolution: () => Effect.die("must not be called"), - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const resolveError = yield* Effect.flip( - service.setThreadResolution({ ...reference, threadId: "t1", resolved: true }), - ); - const replyError = yield* Effect.flip( - service.replyToThread({ ...reference, threadId: "t1", body: "hi" }), - ); - - assert.strictEqual(resolveError._tag, "PullRequestOperationError"); - assert.strictEqual(replyError._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host with no reactions", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: false, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses to react on a host whose capabilities omit reactions entirely", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - setReaction: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - content: "heart", - reacted: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("passes a reaction through with its subject id on a host that has them", () => - Effect.gen(function* () { - let received: { - readonly subjectId: string | undefined; - readonly content: string; - readonly reacted: boolean; - } | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - setReaction: (input) => { - received = { - subjectId: input.subjectId, - content: input.content, - reacted: input.reacted, - }; - return Effect.void; - }, - }), - ], - }); - - yield* service.setReaction({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - subjectId: "IC_1", - content: "heart", - reacted: true, - }); - - assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true }); - }), -); - -it.effect("rereads shared activity for a strict read", () => - Effect.gen(function* () { - let activityCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: activityCalls, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - yield* service.activity(reference); - assert.strictEqual((yield* service.activity(reference)).commentCount, 1); - assert.strictEqual( - (yield* service.activity({ ...reference, allowStale: false })).commentCount, - 2, - ); - assert.strictEqual((yield* service.activity(reference)).commentCount, 2); - }), -); - -it.effect("invalidates the cached activity after reacting, like the other mutations", () => - Effect.gen(function* () { - let activityCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - yield* service.refreshAfterTurn(reference.projectId); - const previousRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 1); - - yield* service.setReaction({ ...reference, content: "heart", reacted: true }); - assert.isAbove( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - previousRefresh, - ); - yield* service.activity(reference); - - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("refuses an empty reply before it reaches the host", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - replyToThread: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.replyToThread({ - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - threadId: "t1", - body: " ", - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - }), -); - -it.effect("refuses a merge strategy the host does not offer", () => - Effect.gen(function* () { - let ranWith: string | null = null; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge", "squash"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - runAction: (input) => { - ranWith = input.mergeMethod ?? "merge"; - return Effect.void; - }, - }), - ], - }); - const reference = { - projectId: "p1" as ProjectId, - repository: "pingdotgg/t3code", - number: 1, - }; - - const error = yield* Effect.flip( - service.runAction({ ...reference, action: "merge", mergeMethod: "rebase" }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(ranWith, null); - - yield* service.runAction({ ...reference, action: "merge", mergeMethod: "squash" }); - assert.strictEqual(ranWith, "squash"); - }), -); - -it.effect("hands the provider the host its repository lives on", () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "enterprise", - workspaceRoot: "/a", - repository: "acme/web", - host: "github.acme.dev", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - hosts.push(input.host); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - - assert.deepStrictEqual(hosts, ["github.acme.dev"]); + number: 42, + }); + assert.strictEqual(preview.number, 42); + const detail = yield* service.detail({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }); + assert.strictEqual(detail.body, "Forgejo detail"); + assert.deepStrictEqual(viewers, ["code.example:3000"]); + } }), ); -it.effect("asks every host the reader's search, rather than filtering what came back", () => +it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => Effect.gen(function* () { - const asked: Array = []; - const listing = (input: { readonly query?: string | undefined }) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }; const service = yield* makeService({ projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), project({ - id: "p2", - title: "on gitlab", - workspaceRoot: "/b", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), + id: "http", + title: "http", + workspaceRoot: "/http", + repository: "team/repo", + provider: SourceControlProviderKind.make("forgejo"), + host: "code.example", + remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { listChangeRequests: listing }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: listing }), - ], + providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], }); - - yield* service.list({ state: "open", query: "pull requests page" }); - - assert.deepStrictEqual(asked, ["pull requests page", "pull requests page"]); + const failure = yield* service + .summary( + { + projectId: "http" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(failure._tag, "PullRequestUnavailableError"); }), ); -it.effect("asks for no search when the reader has typed nothing", () => +it.effect("resolves a project's repository identity when its shell has none cached", () => Effect.gen(function* () { - const asked: Array = []; + const resolved = project({ + id: "web", + title: "web", + workspaceRoot: "/web", + repository: "acme/web", + }); const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], + projects: [{ ...resolved, repositoryIdentity: null }], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: (input) => { - asked.push(input.query); - return Effect.succeed({ items: [], truncated: false, continues: true }); - }, + getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], + resolveRepositoryIdentity: () => Effect.succeed(resolved.repositoryIdentity ?? null), }); - yield* service.list({ state: "open" }); + const summary = yield* service.summary( + { projectId: "web" as ProjectId, host: "github.com", repository: "acme/web", number: 7 }, + { recoverTransientFailure: false }, + ); - assert.deepStrictEqual(asked, [undefined]); + assert.strictEqual(summary.number, 7); }), ); -it.effect("asks another checkout who is signed in when the first one cannot answer", () => +it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { - const asked: string[] = []; + const seen: Array<{ cwd: string; repository: string; host: string }> = []; const service = yield* makeService({ projects: [ - project({ - id: "p1", - title: "t3code (stale worktree)", - workspaceRoot: "/gone", - repository: "pingdotgg/t3code", - }), - project({ - id: "p2", - title: "t3code", - workspaceRoot: "/healthy", - repository: "pingdotgg/t3code", - }), + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: (input) => { - asked.push(input.cwd); - return input.cwd === "/gone" - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getViewer", - reason: "failed", - detail: "not a git repository", - }), - ) - : Effect.succeed("bilal"); - }, - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); }), }), ], }); - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(asked, ["/gone", "/healthy"]); - assert.strictEqual(result.entries.length, 1); - assert.strictEqual(result.providers[0]?.configured, true); - }), -); - -it.effect("refuses to ask for a review on a host that cannot, before any call is made", () => - Effect.gen(function* () { - let asked = false; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: { request: false, listCandidates: false }, - }, - getViewerPermissions: () => { - asked = true; - return Effect.die("must not be called"); - }, - setReviewerRequest: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot ask somebody for a review."); - assert.isFalse(asked); + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); }), ); -it.effect("refuses the candidate list on a host that has no such list to give", () => +it.effect("routes Azure reads and writes through the requested organization's checkout", () => Effect.gen(function* () { + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + }), + ), providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: false, - comment: false, - actions: ["merge"], - mergeMethods: ["merge"], - search: false, - reactions: true, - review: FULL_REVIEW, - reviewers: { request: true, listCandidates: false }, - }, - listReviewerCandidates: () => Effect.die("must not be called"), + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), }), ], }); - - const error = yield* Effect.flip( - service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot say who may review a change request."); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); }), ); -it.effect("refuses a review request this viewer may not make, and says what access it takes", () => +it.effect.each([ + { + host: "ssh.dev.azure.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", + }, + { + host: "vs-ssh.visualstudio.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", + }, + { + host: "org-b.visualstudio.com", + repository: "DefaultCollection/project/_git/web", + remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", + }, +])("routes Azure URL reads and writes through a $host checkout", (checkout) => Effect.gen(function* () { - let sent = false; + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: SourceControlProviderKind.make("azure-devops"), + ...checkout, + }); const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + repository, + }), + ), + target, + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); }), - setReviewerRequest: () => { - sent = true; - return Effect.void; - }, }), ], }); - - const error = yield* Effect.flip( - service.requestReviewers({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - reviewers: [{ id: "octocat", kind: "user" }], - requested: true, - }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "You need write access on this repository to ask for a review."); - assert.isFalse(sent); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); }), ); -it.effect("keeps the menu from a viewer who may not ask, which is all it is for", () => +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => Effect.gen(function* () { const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: SourceControlProviderKind.make("azure-devops"), + host: "dev.azure.com", + }), + ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewerPermissions: () => - Effect.succeed({ - actions: [], - comment: true, - resolve: false, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: false, - }), - listReviewerCandidates: () => Effect.die("must not be called"), + fakeProvider(SourceControlProviderKind.make("azure-devops"), { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), }), ], }); - - const error = yield* Effect.flip( - service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }), + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + const readError = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), ); - - assert.include(error.message, "You need write access on this repository to ask for a review."); + const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); + assert.strictEqual(readError._tag, "PullRequestUnavailableError"); + assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); }), ); -it.effect("hands the host's own candidate list back, and asks for it with the change request", () => +it.effect("prefers a matching checkout over a non-repository project's directory", () => Effect.gen(function* () { - let askedFor: number | null = null; + const seen: string[] = []; const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + projects: [ + project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" }), + project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), + ], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - listReviewerCandidates: (input) => { - askedFor = input.number; - return Effect.succeed({ - candidates: [ - { - id: "octocat", - kind: "user", - login: "octocat", - name: null, - avatarUrl: null, - isRequested: true, - }, - ], - truncated: false, - continues: true, - }); - }, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(input.cwd); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), }), ], }); + yield* service.summary( + { + projectId: "inbox" as ProjectId, + host: "github.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ); + assert.deepStrictEqual(seen, ["/api"]); + }), +); - const list = yield* service.reviewerCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, +// regression: #17167 refresh linked PRs without a checkout +it.effect("requires an explicit host for a non-repository project's linked PR", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], + providers: [fakeProvider(SourceControlProviderKind.make("github"))], }); - - assert.strictEqual(askedFor, 4); - assert.deepStrictEqual( - list.candidates.map((candidate) => candidate.login), - ["octocat"], - ); + const error = yield* service + .summary( + { + projectId: "inbox" as ProjectId, + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestUnavailableError"); }), ); -it.effect("refuses a label change on a host that has not said it takes one", () => +it.effect("rereads shared activity for a strict read", () => Effect.gen(function* () { - let changed = false; + let activityCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - setLabels: () => { - changed = true; - return Effect.void; + getChangeRequestActivity: () => { + activityCalls += 1; + return Effect.succeed({ + comments: [], + commentCount: activityCalls, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }); }, }), ], }); - const error = yield* Effect.flip( - service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - labels: ["bug"], - applied: true, - }), + yield* service.activity(reference); + assert.strictEqual((yield* service.activity(reference)).commentCount, 1); + assert.strictEqual( + (yield* service.activity({ ...reference, allowStale: false })).commentCount, + 2, ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "cannot change the labels"); - assert.isFalse(changed); + assert.strictEqual((yield* service.activity(reference)).commentCount, 2); }), ); -it.effect("refuses a label change this viewer may not make, and says what access it takes", () => +it.effect("refuses a review request this viewer may not make, and says what access it takes", () => Effect.gen(function* () { - let changed = false; + let sent = false; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, getViewerPermissions: () => Effect.succeed({ - actions: [], + actions: ["ready", "draft", "close", "reopen"], comment: true, - resolve: false, + resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, - labels: false, }), - listLabelCandidates: () => Effect.die("must not be called"), - setLabels: () => { - changed = true; + setReviewerRequest: () => { + sent = true; return Effect.void; }, }), ], }); - const listError = yield* Effect.flip( - service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - assert.include(listError.message, "You need triage access on this repository"); - const error = yield* Effect.flip( - service.setLabels({ + service.requestReviewers({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1, - labels: ["bug"], - applied: true, + reviewers: [{ id: "octocat", kind: "user" }], + requested: true, }), ); - assert.include(error.message, "You need triage access on this repository"); - assert.isFalse(changed); - }), -); - -it.effect("hands a label change to the host, and reads the labels back for the menu", () => - Effect.gen(function* () { - let received: { labels: ReadonlyArray; applied: boolean } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - labels: true, - }, - listLabelCandidates: () => - Effect.succeed({ - candidates: [{ name: "bug", color: null, description: null, isApplied: false }], - truncated: false, - }), - setLabels: (input) => { - received = { labels: input.labels, applied: input.applied }; - return Effect.void; - }, - }), - ], - }); - - const list = yield* service.labelCandidates({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - }); - assert.deepStrictEqual( - list.candidates.map((label) => label.name), - ["bug"], - ); - - yield* service.setLabels({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 4, - labels: ["bug"], - applied: false, - }); - assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); - }), -); - -it.effect("answers a repeated listing from cache, and concurrent readers share one request", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.list({ state: "open" })], { - concurrency: "unbounded", - }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 1); - yield* service.list({ state: "all" }); - assert.strictEqual(hostCalls, 2); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "You need write access on this repository to ask for a review."); + assert.isFalse(sent); }), ); @@ -4083,106 +1991,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = ); assert.strictEqual(viewerCalls, 1); - assert.strictEqual(listCalls, 3); - }), -); - -it.effect("uses five host reads for the normal indexed-repository page workflow", () => - Effect.gen(function* () { - let viewerCalls = 0; - let searchCalls = 0; - let fallbackCalls = 0; - let statsCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => - Effect.sync(() => { - viewerCalls += 1; - return "bilal"; - }), - listChangeRequestsAcross: (input) => - Effect.sync(() => { - searchCalls += 1; - return { - items: - input.involvement === "all" - ? [batchedChangeRequest(1, "acme/web", "2026-07-02T00:00:00Z")] - : [], - truncated: false, - }; - }), - listChangeRequests: () => - Effect.sync(() => { - fallbackCalls += 1; - return { items: [], truncated: false, continues: true }; - }), - listChangeRequestStats: () => - Effect.sync(() => { - statsCalls += 1; - return [{ repository: "acme/web", number: 1, additions: 3, deletions: 1 }]; - }), - }), - ], - }); - - const baseline = yield* service.list({ state: "open", involvement: "all" }); - yield* Effect.all( - [ - service.list({ state: "open", involvement: "authored" }), - service.list({ state: "open", involvement: "reviewing" }), - ], - { concurrency: "unbounded" }, - ); - yield* service.listStats({ - refs: baseline.entries.map(({ projectId, repository, number }) => ({ - projectId, - repository, - number, - })), - }); - - assert.deepStrictEqual( - { viewerCalls, searchCalls, fallbackCalls, statsCalls }, - { viewerCalls: 1, searchCalls: 3, fallbackCalls: 0, statsCalls: 1 }, - ); - }), -); - -it.effect("returns the refreshed listing on the first read after its cache expires", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ - items: [changeRequest(hostCalls, "2026-07-02T00:00:00Z")], - truncated: false, - continues: false, - }); - }, - }), - ], - }); - - const first = yield* service.list({ state: "open" }); - assert.deepStrictEqual( - first.entries.map((entry) => entry.number), - [1], - ); - - yield* TestClock.adjust("31 seconds"); - const refreshed = yield* service.list({ state: "open" }); - - assert.strictEqual(hostCalls, 2); - assert.deepStrictEqual( - refreshed.entries.map((entry) => entry.number), - [2], - ); + assert.strictEqual(listCalls, 3); }), ); @@ -4284,85 +2093,6 @@ it.effect( }), ); -it.effect("keeps unrelated PRs warm after a mutation, explicit refresh, and project turn", () => - Effect.gen(function* () { - const calls: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - calls.push(`${input.repository}/${input.number}`); - return { ...hostedChangeRequest("body"), number: input.number }; - }), - }), - ], - }); - const refs = [ - { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }, - { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }, - { projectId: "p2" as ProjectId, repository: "acme/docs", number: 3 }, - ]; - const readAll = Effect.forEach(refs, (ref) => service.summary({ ...ref, allowStale: false })); - yield* readAll; - yield* service.invalidate({ reference: { ...refs[0]!, host: "github.com" } }); - yield* readAll; - assert.deepStrictEqual(calls, ["acme/web/1", "acme/web/2", "acme/docs/3", "acme/web/1"]); - yield* service.comment({ ...refs[0]!, body: "hello" }); - yield* readAll; - assert.deepStrictEqual(calls.slice(4), ["acme/web/1"]); - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* readAll; - assert.deepStrictEqual(calls.slice(5), ["acme/web/1", "acme/web/2"]); - }), -); - -it.effect( - "keeps matching PR numbers on different hosts separate and refreshes the serving project", - () => - Effect.gen(function* () { - const hosts: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "public", workspaceRoot: "/a", repository: "acme/web" }), - project({ - id: "p2", - title: "enterprise", - workspaceRoot: "/b", - repository: "acme/web", - host: "enterprise.test", - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: (input) => - Effect.sync(() => { - hosts.push(input.host); - return hostedChangeRequest("body"); - }), - }), - ], - }); - const own = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const other = { ...own, host: "enterprise.test" }; - const readBoth = Effect.all([service.summary(own), service.summary(other)]); - yield* readBoth; - yield* service.invalidate({ reference: { ...own, host: "github.com" } }); - yield* readBoth; - assert.deepStrictEqual(hosts, ["github.com", "enterprise.test", "github.com"]); - yield* service.invalidate({ reference: own }); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(3), ["github.com"]); - yield* service.refreshAfterTurn("p2" as ProjectId); - yield* readBoth; - assert.deepStrictEqual(hosts.slice(4), ["enterprise.test"]); - }), -); - it.effect("does not revive old summaries when project epochs are evicted", () => Effect.gen(function* () { let title = "old"; @@ -4385,44 +2115,6 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); -it.effect("explicit and turn invalidations make the next listing ask the host again", () => - Effect.gen(function* () { - let hostCalls = 0; - let viewerCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - viewerCalls += 1; - return Effect.succeed("bilal"); - }, - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ items: [], truncated: false, continues: false }); - }, - }), - ], - }); - - yield* service.list({ state: "open" }); - yield* service.invalidate({}); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(viewerCalls, 2); - - yield* service.invalidate({ reference }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - yield* service.refreshAfterTurn("p1" as ProjectId); - const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.list({ state: "open" }); - assert.isAbove(refresh, 0); - assert.strictEqual(hostCalls, 3); - }), -); - it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => Effect.scoped( Effect.gen(function* () { @@ -4476,77 +2168,6 @@ it.effect("close and reopen notify subscribed readers after invalidating their c ), ); -it.effect("explicit invalidation refreshes origin readers after a routed host mutation", () => - Effect.scoped( - Effect.gen(function* () { - let state: "open" | "closed" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), - }), - ], - }); - yield* service.refreshAfterTurn(reference.projectId); - let revision = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); - yield* service.invalidate({ reference }); - assert.strictEqual( - Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), - revision, - ); - assert.strictEqual((yield* service.summary(reference)).state, "open"); - - for (const nextState of ["closed", "open"] as const) { - const refreshed = yield* service.subscribeRefreshes.pipe( - Stream.drop(1), - Stream.take(1), - Stream.mapEffect((nextRevision) => - service - .summary(reference) - .pipe(Effect.map((summary) => ({ revision: nextRevision, state: summary.state }))), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - state = nextState; - yield* service.invalidate({ reference }, { notifyReaders: true }); - const result = Option.getOrThrow(yield* Fiber.join(refreshed)); - assert.strictEqual(result.state, nextState); - assert.isAbove(result.revision, revision); - revision = result.revision; - } - }), - ), -); - -it.effect("does not cache a failed listing", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getViewer: () => { - hostCalls += 1; - return hostCalls === 1 ? Effect.fail(requestFailed) : Effect.succeed("bilal"); - }, - }), - ], - }); - - const error = yield* Effect.flip(service.list({ state: "open" })); - assert.strictEqual(error._tag, "PullRequestOperationError"); - const second = yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - assert.strictEqual(second.providers[0]?.configured, true); - }), -); - it.effect("reads a host's repositories in one search, and files the rows back under each", () => Effect.gen(function* () { const asked: Array> = []; @@ -4749,36 +2370,6 @@ it.effect("asks on its own for a repository a search answered nothing for", () = ); }), ); -it.effect("reads the repositories one at a time when the search itself fails", () => - Effect.gen(function* () { - const separately: string[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequests: ({ repository }) => { - separately.push(repository); - return Effect.succeed({ - items: [changeRequest(1, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }); - }, - listChangeRequestsAcross: () => Effect.fail(requestFailed), - }), - ], - }); - - const result = yield* service.list({ state: "open" }); - - assert.deepStrictEqual(separately.toSorted(), ["acme/docs", "acme/web"]); - assert.deepStrictEqual(result.errors, []); - assert.strictEqual(result.entries.length, 2); - }), -); it.effect("fills in the line counts for the rows it is given", () => Effect.gen(function* () { const asked: Array = []; @@ -4832,58 +2423,6 @@ it.effect("fills in the line counts for the rows it is given", () => ]); }), ); -it.effect( - "reuses counts across overlapping pages until expiry, explicit invalidation, or a reference changes", - () => - Effect.gen(function* () { - const asked: number[][] = []; - const ref = (number: number) => ({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number, - }); - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: (input) => { - asked.push(input.changeRequests.map((ref) => ref.number)); - return Effect.succeed( - input.changeRequests.map((ref) => ({ ...ref, additions: 12, deletions: 3 })), - ); - }, - }), - ], - }); - - yield* service.listStats({ refs: [ref(1), ref(2)] }); - const overlapping = yield* service.listStats({ refs: [ref(2), ref(3)] }); - assert.deepStrictEqual( - overlapping.stats.map((stat) => stat.number), - [2, 3], - ); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3]]); - - yield* service.invalidate({ reference: ref(2) }); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2]]); - - yield* TestClock.adjust("61 seconds"); - yield* service.listStats({ refs: [ref(1), ref(2), ref(3)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3]]); - - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* service.listStats({ refs: [ref(1)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1]]); - - yield* service.invalidate({}); - yield* service.listStats({ refs: [ref(1)] }); - assert.deepStrictEqual(asked, [[1, 2], [3], [2], [1, 2, 3], [1], [1]]); - }), -); it.effect("reads the fresh diff when detail or summary discovers a changed revision", () => Effect.gen(function* () { @@ -4906,195 +2445,37 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis yield* Deferred.await(releaseSummary); return result; }), - getDiff: () => - Effect.sync(() => { - diffCalls += 1; - return { patch, truncated: false, nextCursor: null }; - }), - }), - ], - }); - - const coldSummary = yield* service.summary(reference).pipe(Effect.forkChild()); - yield* Deferred.await(summaryStarted); - yield* service.detail(reference); - assert.strictEqual((yield* service.diff(reference)).patch, "old patch"); - revision = "2026-07-02T00:01:00Z"; - patch = "new patch"; - yield* TestClock.adjust("61 seconds"); - yield* service.detail(reference); - yield* Effect.yieldNow; - assert.strictEqual((yield* service.detail(reference)).updatedAt, revision); - yield* Deferred.succeed(releaseSummary, undefined); - yield* Fiber.join(coldSummary); - yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual((yield* service.diff(reference)).patch, "new patch"); - assert.strictEqual(diffCalls, 2); - - revision = "2026-07-02T00:02:00Z"; - patch = "summary-discovered patch"; - yield* TestClock.adjust("61 seconds"); - yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual((yield* service.diff(reference)).patch, patch); - assert.strictEqual(diffCalls, 3); - }), -); - -it.effect("keeps the rows when the line counts cannot be read", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => Effect.fail(requestFailed), - }), - ], - }); - - const result = yield* service.listStats({ - refs: [{ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }], - }); - - assert.deepStrictEqual(result.stats, []); - }), -); - -it.effect( - "serves core detail without waiting for activity, and shares activity between clients", - () => - Effect.gen(function* () { - let coreCalls = 0; - let activityCalls = 0; - let statsCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - listChangeRequestStats: () => { - statsCalls += 1; - return Effect.succeed([]); - }, - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "Ready before the conversation", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - getChangeRequestActivity: () => { - activityCalls += 1; - return Effect.succeed({ - comments: [], - commentCount: 0, - commentsTruncated: true, - reviewThreadsTruncated: true, - reviewThreads: [], - commits: [], - }); - }, - }), - ], - }); - - const core = yield* service.detail(reference); - assert.strictEqual(core.body, "Ready before the conversation"); - assert.strictEqual(coreCalls, 1); - assert.strictEqual(activityCalls, 0); - - const counts = yield* service.listStats({ refs: [reference] }); - assert.strictEqual(statsCalls, 0); - assert.deepStrictEqual(counts.stats, [ - { - ...reference, - additions: core.additions, - deletions: core.deletions, - }, - ]); - - const activities = yield* Effect.all( - [service.activity(reference), service.activity(reference)], - { - concurrency: 2, - }, - ); - assert.strictEqual(activityCalls, 1); - assert.isTrue(activities.every((activity) => activity.reviewThreadsTruncated === true)); - - yield* service.invalidate({ reference }); - yield* service.activity(reference); - assert.strictEqual(activityCalls, 2); - }), -); - -it.effect("shares linked summaries and reuses them for display without asking the host again", () => - Effect.gen(function* () { - let calls = 0; - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.sync(() => { - calls += 1; - return failing; - }).pipe( - Effect.tap(() => Effect.yieldNow), - Effect.flatMap((shouldFail) => - shouldFail - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequestSummary", - reason: "failed", - detail: "HTTP 504", - }), - ) - : Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - ), - ), + getDiff: () => + Effect.sync(() => { + diffCalls += 1; + return { patch, truncated: false, nextCursor: null }; + }), }), ], }); - yield* Effect.all( - [ - service.summary(reference, { recoverTransientFailure: false }), - service.summary(reference, { recoverTransientFailure: false }), - ], - { concurrency: "unbounded" }, - ); - assert.strictEqual(calls, 1); - + const coldSummary = yield* service.summary(reference).pipe(Effect.forkChild()); + yield* Deferred.await(summaryStarted); + yield* service.detail(reference); + assert.strictEqual((yield* service.diff(reference)).patch, "old patch"); + revision = "2026-07-02T00:01:00Z"; + patch = "new patch"; yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - - const stale = yield* service.summary(reference); - assert.strictEqual(stale.updatedAt, "2026-07-02T00:00:00Z"); - assert.strictEqual(calls, 2); + yield* service.detail(reference); + yield* Effect.yieldNow; + assert.strictEqual((yield* service.detail(reference)).updatedAt, revision); + yield* Deferred.succeed(releaseSummary, undefined); + yield* Fiber.join(coldSummary); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, "new patch"); + assert.strictEqual(diffCalls, 2); - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.summary(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); + revision = "2026-07-02T00:02:00Z"; + patch = "summary-discovered patch"; + yield* TestClock.adjust("61 seconds"); + yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual((yield* service.diff(reference)).patch, patch); + assert.strictEqual(diffCalls, 3); }), ); @@ -5289,64 +2670,6 @@ it.effect("rejects mismatched routing credentials before use and preserves actio }), ); -it.effect("answers a known pull request immediately while the host refreshes", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - let calls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.gen(function* () { - calls += 1; - if (calls > 1) yield* Deferred.await(gate); - return hostedChangeRequest("cached body", 4); - }), - }), - ], - }); - - const first = yield* service.detail(reference); - assert.strictEqual(first.body, "cached body"); - assert.strictEqual(first.additions, 4); - - yield* TestClock.adjust("61 seconds"); - const second = yield* service.detail(reference); - assert.strictEqual(second.body, "cached body"); - assert.strictEqual(second.additions, 4); - yield* Effect.yieldNow; - assert.strictEqual(calls, 2); - }), -); - -it.effect("does not ask the host again for a linked summary it already holds", () => - Effect.gen(function* () { - let calls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequestSummary: () => - Effect.sync(() => { - calls += 1; - return changeRequest(1, "2026-07-02T00:00:00Z"); - }), - }), - ], - }); - - const first = yield* service.summary(reference); - assert.strictEqual(first.title, "Change request 1"); - yield* TestClock.adjust("61 seconds"); - const second = yield* service.summary(reference); - assert.strictEqual(second.title, "Change request 1"); - assert.strictEqual(calls, 1); - }), -); - it.effect( "opening detail preserves enriched linked summaries and updates draft and diff fields", () => @@ -5415,101 +2738,6 @@ it.effect("reuses an observed merged state for strict settlement reads", () => }), ); -it.effect("announces state a detail read sees first or newly", () => - Effect.gen(function* () { - let detail = { - state: "open" as "open" | "closed" | "merged", - updatedAt: "2026-07-02T00:00:00Z", - }; - let summaryState: "open" | "merged" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-04T00:00:00Z"), - state: summaryState, - }), - }), - ], - }); - const announced: Array = []; - yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => - Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), - ).pipe(Effect.forkChild({ startImmediately: true })); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; - }); - - yield* readDetail; - yield* readDetail; - assert.deepStrictEqual(announced, ["github.com/acme/web#1"]); - - detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 2); - - summaryState = "merged"; - yield* service.summary(reference, { recoverTransientFailure: false }); - yield* Effect.yieldNow; - assert.strictEqual(announced.length, 2); - detail = { state: "merged", updatedAt: "2026-07-04T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 3); - - detail = { state: "open", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 3); - }), -); - -it.effect("announces a reported state without trusting it", () => - Effect.gen(function* () { - let detail = { state: "open" as "open" | "closed", updatedAt: "2026-07-02T00:00:00Z" }; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), ...detail }), - }), - ], - }); - const announced: Array = []; - yield* Stream.runForEach(yield* service.subscribeStateChanges, (key) => - Effect.sync(() => announced.push(`${key.host}/${key.repository}#${key.number}`)), - ).pipe(Effect.forkChild({ startImmediately: true })); - const note = (state: "open" | "closed" | "merged") => - service.reportState({ reference, state }).pipe(Effect.andThen(Effect.yieldNow)); - const readDetail = Effect.gen(function* () { - yield* service.invalidate({ reference }); - yield* service.detail(reference); - yield* Effect.yieldNow; - }); - - yield* readDetail; - yield* note("closed"); - yield* note("closed"); - assert.strictEqual(announced.length, 2); - yield* TestClock.adjust("5 minutes"); - yield* note("closed"); - assert.strictEqual(announced.length, 3); - - yield* note("open"); - assert.strictEqual(announced.length, 4); - - yield* note("merged"); - detail = { state: "closed", updatedAt: "2026-07-03T00:00:00Z" }; - yield* readDetail; - assert.strictEqual(announced.length, 6); - }), -); - it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -5538,230 +2766,31 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary }); const first = yield* service.detail(reference); - assert.strictEqual(first.title, "Change request 1"); - - summaryTitle = "merged title"; - summaryState = "merged"; - yield* TestClock.adjust("61 seconds"); - const settled = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(settled.title, "merged title"); - assert.strictEqual(settled.state, "merged"); - - yield* TestClock.adjust("16 seconds"); - const stale = yield* service.detail(reference); - assert.strictEqual(stale.title, "Change request 1"); - yield* Effect.yieldNow; - - const display = yield* service.summary(reference); - assert.strictEqual(display.title, "merged title"); - assert.strictEqual(display.state, "merged"); - assert.strictEqual(detailCalls, 2); - - summaryTitle = "updated after merge"; - yield* TestClock.adjust("61 seconds"); - assert.strictEqual((yield* service.summary(reference)).title, "merged title"); - const refreshed = yield* service.summary({ ...reference, allowStale: false }); - assert.strictEqual(refreshed.title, "updated after merge"); - assert.strictEqual(refreshed.state, "merged"); - }), -); - -it.effect("does not let a still-cached detail overwrite a fresher linked summary", () => - Effect.gen(function* () { - let summaryTitle = "old title"; - let summaryState: "open" | "merged" = "open"; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => Effect.succeed(hostedChangeRequest("old body", 4)), - getChangeRequestSummary: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - title: summaryTitle, - state: summaryState, - }), - }), - ], - }); - - const first = yield* service.detail(reference); - assert.strictEqual(first.title, "Change request 1"); - - summaryTitle = "merged title"; - summaryState = "merged"; - const settled = yield* service.summary(reference, { recoverTransientFailure: false }); - assert.strictEqual(settled.state, "merged"); - - const cached = yield* service.detail(reference); - assert.strictEqual(cached.title, "Change request 1"); - yield* Effect.yieldNow; - - const display = yield* service.summary(reference); - assert.strictEqual(display.title, "merged title"); - assert.strictEqual(display.state, "merged"); - }), -); - -it.effect("tells the client when the host has no pull request under that number", () => - Effect.gen(function* () { - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 121 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "not-found", - detail: "Pull request not found. Check the PR number or URL and try again.", - }), - ), - }), - ], - }); - - const error = yield* Effect.flip(service.detail(reference)); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(error._tag === "PullRequestOperationError" && error.reason, "not-found"); - }), -); - -it.effect("keeps recent detail on a transient refresh failure but not after invalidation", () => - Effect.gen(function* () { - let failing = false; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - failing - ? Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getChangeRequest", - reason: "failed", - detail: "spawn gh EAGAIN", - }), - ) - : Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "last good body", - changedFiles: 2, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }), - }), - ], - }); - - yield* service.detail(reference); - yield* TestClock.adjust("61 seconds"); - failing = true; - const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false })); - assert.strictEqual(strict._tag, "PullRequestOperationError"); - const stale = yield* service.detail(reference); - assert.strictEqual(stale.body, "last good body"); - - yield* service.invalidate({ reference }); - const invalidated = yield* Effect.flip(service.detail(reference)); - assert.strictEqual(invalidated._tag, "PullRequestOperationError"); - }), -); - -it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => - Effect.gen(function* () { - const detailWith = (autoMergeEnabled: boolean | undefined) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), - }), - }), - ], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - - assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true); - assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false); - assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled); - }), -); - -it.effect("narrows the rows of a host that ignored the filters it was handed", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true }, - changeRequest(2, "2026-07-01T00:00:00Z"), - ], - truncated: false, - continues: false, - }), - }), - ], - }); + assert.strictEqual(first.title, "Change request 1"); - const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); + summaryTitle = "merged title"; + summaryState = "merged"; + yield* TestClock.adjust("61 seconds"); + const settled = yield* service.summary(reference, { recoverTransientFailure: false }); + assert.strictEqual(settled.title, "merged title"); + assert.strictEqual(settled.state, "merged"); - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [2], - ); + yield* TestClock.adjust("16 seconds"); + const stale = yield* service.detail(reference); + assert.strictEqual(stale.title, "Change request 1"); + yield* Effect.yieldNow; + + const display = yield* service.summary(reference); + assert.strictEqual(display.title, "merged title"); + assert.strictEqual(display.state, "merged"); + assert.strictEqual(detailCalls, 2); + + summaryTitle = "updated after merge"; + yield* TestClock.adjust("61 seconds"); + assert.strictEqual((yield* service.summary(reference)).title, "merged title"); + const refreshed = yield* service.summary({ ...reference, allowStale: false }); + assert.strictEqual(refreshed.title, "updated after merge"); + assert.strictEqual(refreshed.state, "merged"); }), ); @@ -5810,45 +2839,6 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label }), ); -it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/a", - repository: "acme/web", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [ - changeRequest(1, "2026-07-02T00:00:00Z"), - { - ...changeRequest(2, "2026-07-01T00:00:00Z"), - author: { login: "bilal", name: null, avatarUrl: null }, - }, - ], - truncated: false, - continues: false, - }), - }), - ], - }); - - const result = yield* service.list({ state: "open", filters: { author: "me" } }); - - assert.deepStrictEqual( - result.entries.map((entry) => entry.number), - [2], - ); - }), -); - it.effect.each([false, true])( "authorizes stack rebases and refreshes sibling layers (cross-host: %s)", (crossHost) => @@ -5993,61 +2983,6 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n }), ); -it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () => - Effect.gen(function* () { - let taken = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge", "close", "update-branch"], - mergeMethods: ["merge"], - updateMethods: ["rebase"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getViewerPermissions: () => - Effect.succeed({ - actions: ["close", "update-branch"], - comment: true, - resolve: true, - verdicts: ["comment"], - requestReviewers: false, - updateMethods: ["rebase"], - }), - runAction: () => { - taken += 1; - return Effect.void; - }, - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; - - const error = yield* Effect.flip( - service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }), - ); - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.strictEqual(taken, 0); - - yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }); - assert.strictEqual(taken, 1); - }), -); - it.effect("judges the review filter only on a host that summarises its reviews", () => Effect.gen(function* () { const service = yield* makeService({ @@ -6074,260 +3009,24 @@ it.effect("judges the review filter only on a host that summarises its reviews", ], truncated: false, continues: true, - }), - }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { - listChangeRequests: () => - Effect.succeed({ - items: [changeRequest(3, "2026-07-02T00:00:00Z")], - truncated: false, - continues: true, - }), - }), - ], - }); - - const none = yield* service.list({ state: "open", filters: { review: "none" } }); - assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); - - const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); - assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); - }), -); - -it.effect("sends only the words a rewrite carries", () => - Effect.gen(function* () { - const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: (input) => { - received.push({ title: input.title, body: input.body }); - return Effect.void; - }, - }), - ], - }); - - yield* service.update({ ...reference, title: "A better title" }); - yield* service.update({ ...reference, body: "" }); - yield* service.update({ ...reference, title: "Both", body: "at once" }); - - assert.deepStrictEqual(received, [ - { title: "A better title", body: undefined }, - { title: undefined, body: "" }, - { title: "Both", body: "at once" }, - ]); - }), -); - -it.effect("refuses a rewrite that changes nothing, before any call is made", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateChangeRequest: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), - ); - - assert.strictEqual(error._tag, "PullRequestOperationError"); - assert.include(error.message, "Nothing was changed."); - }), -); - -it.effect("refuses to rewrite anything on a host that never claimed it", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - updateChangeRequest: () => Effect.die("must not be called"), - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - - const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" })); - const commentRefused = yield* Effect.flip( - service.updateComment({ - ...reference, - commentId: "IC_1", - kind: "issue-comment", - body: "New", - }), - ); - - assert.include(rewriteRefused.message, "cannot rewrite a change request."); - assert.include(commentRefused.message, "cannot rewrite a comment."); - }), -); - -it.effect("passes a rewritten remark through with the id and kind it arrived under", () => - Effect.gen(function* () { - let received: { id: string; kind: string; body: string } | null = null; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: (input) => { - received = { id: input.commentId, kind: input.kind, body: input.body }; - return Effect.void; - }, - }), - ], - }); - - yield* service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - - assert.deepStrictEqual(received, { - id: "PRRC_1", - kind: "review-comment", - body: "Second thoughts", - }); - }), -); - -it.effect("refuses a remark rewritten into nothing but whitespace", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - updateComment: () => Effect.die("must not be called"), - }), - ], - }); - - const error = yield* Effect.flip( - service.updateComment({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - commentId: "IC_1", - kind: "issue-comment", - body: " \n ", - }), - ); - - assert.include(error.message, "A comment cannot be empty."); - }), -); - -it.effect("forgets the cached detail after a rewrite or terminal turn", () => - Effect.gen(function* () { - let coreCalls = 0; - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => { - coreCalls += 1; - return Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, - }); - }, - }), - ], - }); - - yield* service.detail(reference); - yield* service.update({ ...reference, title: "Renamed" }); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 2); - - yield* service.refreshAfterTurn("p1" as ProjectId); - yield* service.detail(reference); - assert.strictEqual(coreCalls, 3); - }), -); - -it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () => - Effect.gen(function* () { - const detailFrom = (provider: PullRequestProviderApi) => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), - ], - providers: [provider], - }); - return yield* service.detail({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - }); - }); - const readable = fakeProvider(SourceControlProviderKind.make("github"), { - getChangeRequest: () => - Effect.succeed({ - ...changeRequest(1, "2026-07-02T00:00:00Z"), - body: "", - changedFiles: 0, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: { - actions: ["merge"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }, + }), + }), + fakeProvider(SourceControlProviderKind.make("gitlab"), { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(3, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), }), + ], }); - const named = yield* detailFrom(readable); - const unnamed = yield* detailFrom({ - ...readable, - getViewer: () => - Effect.fail(unusable(SourceControlProviderKind.make("github"), "unauthenticated")), - }); + const none = yield* service.list({ state: "open", filters: { review: "none" } }); + assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); - assert.strictEqual(named.viewer, "bilal"); - assert.strictEqual(unnamed.viewer, undefined); + const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); + assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); }), ); @@ -6390,166 +3089,6 @@ it.effect("partial host viewed writes invalidate marks but keep the diff cached" }), ); -it.effect("keeps the diff cached across a file being ticked off", () => - Effect.gen(function* () { - let diffReads = 0; - let viewedReads = 0; - let state: "viewed" | "dismissed" = "viewed"; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "host", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getDiff: () => { - diffReads += 1; - return Effect.succeed({ patch: "@@", truncated: false, nextCursor: null }); - }, - getFilesViewed: () => { - viewedReads += 1; - return Effect.succeed({ - files: [{ path: "src/a.ts", state }], - truncated: false, - }); - }, - setFilesViewed: () => Effect.void, - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - - yield* service.diff(reference); - yield* service.filesViewed(reference); - yield* service.setFilesViewed({ ...reference, files: [{ path: "src/a.ts", viewed: false }] }); - yield* service.diff(reference); - yield* service.filesViewed(reference); - - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 2); - - state = "dismissed"; - yield* service.invalidate({ reference, filesViewedOnly: true }); - yield* service.diff(reference); - assert.deepStrictEqual((yield* service.filesViewed(reference)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - assert.strictEqual(diffReads, 1); - assert.strictEqual(viewedReads, 3); - }), -); - -it.effect("returns large diff slices intact without retaining them in either cache", () => - Effect.gen(function* () { - let reads = 0; - const patch = "\u{1f4bb}".repeat(140_000); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { patch, truncated: false, nextCursor: "2" }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - for (const input of [ - reference, - { ...reference, cursor: "2" }, - { ...reference, commit: "a".repeat(40) }, - ]) { - const before = reads; - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.deepStrictEqual(yield* service.diff(input), { - patch, - truncated: false, - nextCursor: "2", - }); - assert.strictEqual(reads, before + 2); - } - }), -); - -it.effect("caches a small replacement after releasing a large diff", () => - Effect.gen(function* () { - let reads = 0; - const largePatch = "x".repeat(300_000); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - reads += 1; - return { - patch: reads === 1 ? largePatch : "@@ small replacement", - truncated: false, - nextCursor: null, - }; - }), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - assert.strictEqual((yield* service.diff(reference)).patch, largePatch); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual((yield* service.diff(reference)).patch, "@@ small replacement"); - assert.strictEqual(reads, 2); - }), -); - -it.effect("releases an expired diff nobody reads again", () => - Effect.gen(function* () { - NodeV8.setFlagsFromString("--expose-gc"); - const collect = NodeVM.runInNewContext("gc") as () => void; - const settle = Effect.promise(async () => { - // A weak reference keeps its target for the rest of the job that created it. - await new Promise((resolve) => setImmediate(resolve)); - collect(); - }); - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => Effect.sync(() => ({ patch: "@@", truncated: false, nextCursor: null })), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; - const read = (input: Parameters[0]) => - Effect.map(service.diff(input), (result) => new WeakRef(result)); - const page = yield* read(reference); - const commit = yield* read({ ...reference, commit: "a".repeat(40) }); - - yield* TestClock.adjust("9 minutes"); - yield* settle; - assert.isDefined(page.deref()); - assert.isDefined(commit.deref()); - - yield* TestClock.adjust("2 minutes"); - yield* settle; - assert.isUndefined(page.deref()); - assert.isUndefined(commit.deref()); - }), -); - const environmentViewedProvider = ( revisions: Map, asked: Array>, @@ -6742,197 +3281,60 @@ it.effect("keeps hosted Forgejo marks with their repository instead of the servi name: "Forgejo", baseUrl: "https://forge.example:3000", }, - }, - provider: undefined as never, - }), - }); - const first = { - projectId: "p1" as ProjectId, - host: "forge.example:3000", - repository: "reviewer/first", - number: 1, - }; - const second = { ...first, repository: "reviewer/second" }; - const files = [{ path: "same.ts", viewed: true }]; - yield* service.setFilesViewed({ ...first, files }); - assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); - yield* service.setFilesViewed({ ...second, files }); - yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); - assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ - { path: "same.ts", state: "viewed" }, - ]); - - projects.push( - project({ - id: "p2", - title: "second repository", - workspaceRoot: "/second", - repository: "reviewer/second", - provider: SourceControlProviderKind.make("forgejo"), - host: "forge.example", - remoteUrl: "https://forge.example:3000/reviewer/second.git", - }), - ); - assert.deepStrictEqual( - (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], - ); - projects.push( - project({ - id: "ssh", - title: "second repository over SSH", - workspaceRoot: "/ssh", - repository: "reviewer/second", - provider: SourceControlProviderKind.make("forgejo"), - host: "ssh.forge.example", - remoteUrl: "git@ssh.forge.example:reviewer/second.git", - }), - ); - assert.deepStrictEqual( - (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, - [{ path: "same.ts", state: "viewed" }], - ); - }), -); - -it.effect("keeps viewed files itself for a host that keeps none of its own", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService( - new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]), - asked, - ); - - const empty = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual(empty, { files: [], truncated: false }); - assert.deepStrictEqual(asked, []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: true }, - ], - }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.strictEqual(marked.truncated, false); - assert.deepStrictEqual( - asked.map((paths) => [...paths].toSorted()), - [["src/a.ts", "src/b.ts"]], - ); - }), -); - -it.effect("holds what a whole-change answer carried, so the next tick reads nothing", () => - Effect.gen(function* () { - const asked: Array> = []; - const head = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: (input) => { - asked.push(input.paths); - return Effect.succeed({ revisions: head, complete: true }); - }, - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - - assert.deepStrictEqual(asked, [["src/a.ts"]]); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - - yield* TestClock.adjust("2 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - }), -); - -it.effect("reads the marks without asking the host what the head has every time", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - }), -); - -it.effect("answers the marks from what it last heard while it asks the host again", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], + }, + provider: undefined as never, + }), }); - revisions.set("src/a.ts", "blob-a-again"); - yield* TestClock.adjust("90 seconds"); - const held = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(held.files, [{ path: "src/a.ts", state: "viewed" }]); - assert.strictEqual(asked.length, 2); - - yield* TestClock.adjust("20 seconds"); - const caught = yield* service.filesViewed(GITLAB_REFERENCE); + const first = { + projectId: "p1" as ProjectId, + host: "forge.example:3000", + repository: "reviewer/first", + number: 1, + }; + const second = { ...first, repository: "reviewer/second" }; + const files = [{ path: "same.ts", viewed: true }]; + yield* service.setFilesViewed({ ...first, files }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, []); + yield* service.setFilesViewed({ ...second, files }); + yield* service.setFilesViewed({ ...first, files: [{ path: "same.ts", viewed: false }] }); + assert.deepStrictEqual((yield* service.filesViewed(second)).files, [ + { path: "same.ts", state: "viewed" }, + ]); - assert.deepStrictEqual(caught.files, [{ path: "src/a.ts", state: "dismissed" }]); - assert.strictEqual(asked.length, 2); + projects.push( + project({ + id: "p2", + title: "second repository", + workspaceRoot: "/second", + repository: "reviewer/second", + provider: SourceControlProviderKind.make("forgejo"), + host: "forge.example", + remoteUrl: "https://forge.example:3000/reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "p2" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); + projects.push( + project({ + id: "ssh", + title: "second repository over SSH", + workspaceRoot: "/ssh", + repository: "reviewer/second", + provider: SourceControlProviderKind.make("forgejo"), + host: "ssh.forge.example", + remoteUrl: "git@ssh.forge.example:reviewer/second.git", + }), + ); + assert.deepStrictEqual( + (yield* service.filesViewed({ ...second, projectId: "ssh" as ProjectId })).files, + [{ path: "same.ts", state: "viewed" }], + ); }), ); -it.effect("asks the host about a file it has not been asked about before", () => +it.effect("keeps viewed files itself for a host that keeps none of its own", () => Effect.gen(function* () { const asked: Array> = []; const service = yield* environmentViewedService( @@ -6943,16 +3345,17 @@ it.effect("asks the host about a file it has not been asked about before", () => asked, ); + const empty = yield* service.filesViewed(GITLAB_REFERENCE); + assert.deepStrictEqual(empty, { files: [], truncated: false }); + assert.deepStrictEqual(asked, []); + yield* service.setFilesViewed({ ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("20 seconds"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], + files: [ + { path: "src/a.ts", viewed: true }, + { path: "src/b.ts", viewed: true }, + ], }); - yield* TestClock.adjust("20 seconds"); const marked = yield* service.filesViewed(GITLAB_REFERENCE); assert.deepStrictEqual( @@ -6962,44 +3365,10 @@ it.effect("asks the host about a file it has not been asked about before", () => { path: "src/b.ts", state: "viewed" }, ], ); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - }), -); - -it.effect("does not let a press about one file keep another file's version alive", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* environmentViewedService(revisions, asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("40 seconds"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.deepStrictEqual(asked, [["src/a.ts"], ["src/b.ts"]]); - - revisions.set("src/a.ts", "blob-a-again"); - yield* TestClock.adjust("30 seconds"); - yield* service.filesViewed(GITLAB_REFERENCE); - assert.strictEqual(asked.length, 3); - - yield* TestClock.adjust("20 seconds"); - const caught = yield* service.filesViewed(GITLAB_REFERENCE); - + assert.strictEqual(marked.truncated, false); assert.deepStrictEqual( - [...caught.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/b.ts", state: "viewed" }, - ], + asked.map((paths) => [...paths].toSorted()), + [["src/a.ts", "src/b.ts"]], ); }), ); @@ -7033,26 +3402,6 @@ it.effect("reports a file pushed to since it was cleared as changed", () => }), ); -it.effect("clears a mark again when the file is put back", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: false }], - }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual(marked.files, []); - assert.deepStrictEqual(asked, [["src/a.ts"]]); - }), -); - it.effect("keeps a deleted file cleared, which the head has no version of at all", () => Effect.gen(function* () { const service = yield* environmentViewedService(new Map(), []); @@ -7101,71 +3450,6 @@ it.effect("leaves a mark alone when the host could not say what the head has of }), ); -it.effect("keeps a file cleared that the press could not learn a version for", () => - Effect.gen(function* () { - const revisions = new Map([["src/past-the-cut.ts", "blob-b"]]); - const unreadable = new Set(["src/past-the-cut.ts"]); - const service = yield* environmentViewedService(revisions, [], unreadable); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/past-the-cut.ts", viewed: true }], - }); - unreadable.delete("src/past-the-cut.ts"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/past-the-cut.ts", state: "viewed" }, - ]); - }), -); - -it.effect("keeps the version it last heard when a later read of the head stops short", () => - Effect.gen(function* () { - const asked: Array> = []; - const revisions = new Map([["src/a.ts", "blob-a"]]); - const unreadable = new Set(); - const service = yield* environmentViewedService(revisions, asked, unreadable); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - - unreadable.add("src/a.ts"); - yield* TestClock.adjust("90 seconds"); - yield* service.filesViewed(GITLAB_REFERENCE); - yield* TestClock.adjust("20 seconds"); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - }), -); - -it.effect("re-asks what the head has of a marked file after a whole-workspace refresh", () => - Effect.gen(function* () { - const revisions = new Map([["src/a.ts", "blob-a"]]); - const service = yield* environmentViewedService(revisions, []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({}); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "dismissed" }, - ]); - }), -); - it.effect("forgets what the head had of a marked file once a mutation moves the head", () => Effect.gen(function* () { const revisions = new Map([["src/a.ts", "blob-a"]]); @@ -7184,57 +3468,6 @@ it.effect("forgets what the head had of a marked file once a mutation moves the }), ); -it.effect("still reports its own marks when the host will not say what the head has", () => - Effect.gen(function* () { - let answering = true; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - capabilities: { - diff: true, - comment: true, - actions: ["merge"], - mergeMethods: ["merge"], - search: true, - reactions: true, - viewedFiles: "environment", - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - }, - getFilesViewed: () => Effect.die("the host keeps no marks of its own"), - setFilesViewed: () => Effect.die("the host keeps no marks of its own"), - getFileRevisions: (input) => - answering - ? Effect.succeed({ - revisions: new Map(input.paths.map((path) => [path, "blob-a"] as const)), - }) - : Effect.fail(requestFailed), - }), - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - answering = false; - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - it.effect("finishes two presses on one file in the order they were made", () => Effect.gen(function* () { const held = yield* Deferred.make(); @@ -7350,114 +3583,23 @@ it.effect("keeps the marks of two Azure repositories of the same name apart", () }); assert.deepStrictEqual((yield* service.filesViewed(AZURE_PLATFORM)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); - }), -); - -it.effect("keeps environment marks apart from another change request's", () => - Effect.gen(function* () { - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - - assert.deepStrictEqual(other.files, []); - }), -); - -it.effect("bounds the paths one change request's held revisions carry", () => - Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map(), asked); - const batch = (prefix: string, count: number) => - Array.from({ length: count }, (_, index) => ({ - path: `${prefix}/${String(index).padStart(4, "0")}.ts`, - viewed: true, - })); - const press = (prefix: string, count: number) => - service.setFilesViewed({ ...GITLAB_REFERENCE, files: batch(prefix, count) }); - - yield* press("a", MAX_FILE_REVISION_PATHS / 2); - yield* press("b", MAX_FILE_REVISION_PATHS / 2); - yield* press("c", 1); - const pressed = asked.length; - - yield* service.filesViewed(GITLAB_REFERENCE); - - assert.strictEqual(asked.length, pressed + 1); - assert.ok(asked.at(-1)?.includes("a/0000.ts")); - }), -); - -it.effect("keeps the marked paths when a whole-change answer is wider than the cap", () => - Effect.gen(function* () { - const head = new Map( - Array.from( - { length: MAX_FILE_REVISION_PATHS + 178 }, - (_, index) => - [`src/f${String(index).padStart(4, "0")}.ts`, `blob-${String(index)}`] as const, - ), - ); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(head, []), - getFileRevisions: () => Effect.succeed({ revisions: head, complete: true }), - }, - ], - }); - const ticked = ["src/f0000.ts", "src/f0500.ts"]; - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: ticked.map((path) => ({ path, viewed: true })), - }); - for (const path of ticked) head.set(path, "blob-moved"); - yield* TestClock.adjust("11 minutes"); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/f0000.ts", state: "dismissed" }, - { path: "src/f0500.ts", state: "dismissed" }, - ], - ); + { path: "src/a.ts", state: "viewed" }, + ]); + assert.deepStrictEqual((yield* service.filesViewed(AZURE_OTHER)).files, []); }), ); -it.effect("keeps the change request being ticked through, not the one pressed first", () => +it.effect("keeps environment marks apart from another change request's", () => Effect.gen(function* () { - const asked: Array> = []; - const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), asked); - const press = (number: number) => - service.setFilesViewed({ - ...GITLAB_REFERENCE, - number, - files: [{ path: "src/a.ts", viewed: true }], - }); + const service = yield* environmentViewedService(new Map([["src/a.ts", "blob-a"]]), []); - yield* press(1); - for (let filled = 0; filled < FILE_REVISIONS_CACHE_CAPACITY; filled += 1) { - yield* press(2 + filled); - yield* press(1); - } + yield* service.setFilesViewed({ + ...GITLAB_REFERENCE, + files: [{ path: "src/a.ts", viewed: true }], + }); + const other = yield* service.filesViewed({ ...GITLAB_REFERENCE, number: 2 }); - assert.strictEqual(asked.length, 1 + FILE_REVISIONS_CACHE_CAPACITY); + assert.deepStrictEqual(other.files, []); }), ); @@ -7496,40 +3638,6 @@ it.effect("keeps one reader's marks on a host that names nobody", () => }), ); -it.effect("puts a listing and a press for one host on a single viewer lookup", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.gen(function* () { - viewerLookups += 1; - yield* Effect.yieldNow; - return "bilal"; - }), - }, - ], - }); - - yield* Effect.all([service.list({ state: "open" }), service.filesViewed(GITLAB_REFERENCE)], { - concurrency: 2, - }); - - assert.strictEqual(viewerLookups, 1); - }), -); - it.effect("carries a bounded number of its own marks and says it held more", () => Effect.gen(function* () { const asked: Array> = []; @@ -7553,248 +3661,6 @@ it.effect("carries a bounded number of its own marks and says it held more", () }), ); -it.effect("records a press while the host is backing off", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - yield* TestClock.adjust("11 minutes"); - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - - assert.deepStrictEqual( - [...(yield* service.filesViewed(GITLAB_REFERENCE)).files].toSorted((left, right) => - left.path.localeCompare(right.path), - ), - [ - { path: "src/a.ts", state: "viewed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - assert.strictEqual(viewerLookups, 2); - }), -); - -it.effect("asks who is reading through a pause only for the press that is waiting on it", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.sync(() => { - viewerLookups += 1; - return "bilal"; - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - getFileRevisions: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getFileRevisions", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - assert.strictEqual(viewerLookups, 1); - yield* TestClock.adjust("11 minutes"); - - const listed = yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(listed._tag, "PullRequestOperationError"); - assert.strictEqual(viewerLookups, 1); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }); - assert.strictEqual(viewerLookups, 2); - }), -); - -it.effect("does not ask a paused host who is reading again after the ask failed", () => - Effect.gen(function* () { - let viewerLookups = 0; - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - { - ...environmentViewedProvider(new Map([["src/a.ts", "blob-a"]]), []), - getViewer: () => - Effect.suspend(() => { - viewerLookups += 1; - return Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ); - }), - listChangeRequests: () => - Effect.succeed({ items: [], truncated: false, continues: true }), - runAction: () => - Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "runAction", - reason: "rate-limited", - detail: "API rate limit exceeded.", - retryAt: 60 * 60 * 1_000, - }), - ), - }, - ], - }); - - yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - assert.strictEqual(viewerLookups, 1); - yield* Effect.flip(service.runAction({ ...GITLAB_REFERENCE, action: "merge" })); - - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - yield* TestClock.adjust("11 minutes"); - yield* Effect.flip(service.list({ state: "open", involvement: "all" })); - assert.strictEqual(viewerLookups, 1); - }), -); - -it.effect("refuses the marks when the host could not be asked who is reading", () => - Effect.gen(function* () { - let answering = true; - const service = yield* environmentViewedServiceWithViewer( - new Map([["src/a.ts", "blob-a"]]), - () => - answering - ? Effect.succeed("bilal") - : Effect.fail( - new PullRequestProviderError({ - provider: SourceControlProviderKind.make("gitlab"), - operation: "getViewer", - reason: "failed", - detail: "glab exited with status 1", - }), - ), - ); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/a.ts", viewed: true }], - }); - answering = false; - yield* TestClock.adjust("11 minutes"); - - const read = yield* Effect.flip(service.filesViewed(GITLAB_REFERENCE)); - const write = yield* Effect.flip( - service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [{ path: "src/b.ts", viewed: true }], - }), - ); - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); - - answering = true; - assert.deepStrictEqual((yield* service.filesViewed(GITLAB_REFERENCE)).files, [ - { path: "src/a.ts", state: "viewed" }, - ]); - }), -); - -it.effect("refuses to track viewed files on a host that does not", () => - Effect.gen(function* () { - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "on gitlab", - workspaceRoot: "/a", - repository: "group/project", - provider: SourceControlProviderKind.make("gitlab"), - }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { - getFilesViewed: () => Effect.die("must not be called"), - setFilesViewed: () => Effect.die("must not be called"), - }), - ], - }); - const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; - - const read = yield* Effect.flip(service.filesViewed(reference)); - const write = yield* Effect.flip( - service.setFilesViewed({ ...reference, files: [{ path: "a.ts", viewed: true }] }), - ); - - assert.strictEqual(read._tag, "PullRequestOperationError"); - assert.strictEqual(write._tag, "PullRequestOperationError"); - }), -); - it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => Effect.gen(function* () { const seen: string[] = []; @@ -7832,65 +3698,6 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect.each([ - { action: "merge", environmentValue: true, projectValue: false, expected: false }, - { action: "merge", environmentValue: false, projectValue: true, expected: true }, - { action: "merge", environmentValue: true, projectValue: undefined, expected: true }, - { action: "merge", environmentValue: false, projectValue: undefined, expected: false }, - { action: "enable-auto-merge", environmentValue: true, projectValue: false, expected: false }, - { action: "enable-auto-merge", environmentValue: false, projectValue: true, expected: true }, - { action: "enable-auto-merge", environmentValue: true, projectValue: undefined, expected: true }, -] as const)( - "resolves agent credits for $action with environment=$environmentValue and project=$projectValue", - ({ action, environmentValue, projectValue, expected }) => - Effect.gen(function* () { - const calls: boolean[] = []; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" }), - ], - settings: { - ...DEFAULT_SERVER_SETTINGS, - removeAgentCreditsOnMerge: environmentValue, - projectSettingsOverrides: - projectValue === undefined - ? {} - : { - ["p1" as ProjectId]: { removeAgentCreditsOnMerge: projectValue }, - }, - }, - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, - actions: ["merge", "enable-auto-merge"], - }, - getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "enable-auto-merge"], - comment: true, - resolve: true, - verdicts: ["comment"], - requestReviewers: false, - }), - runAction: (input) => - Effect.sync(() => { - calls.push(input.removeAgentCreditsOnMerge === true); - }), - }), - ], - }); - yield* service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action, - }); - assert.deepStrictEqual(calls, [expected]); - }), -); - it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () => Effect.gen(function* () { let commandCalls = 0; @@ -8046,47 +3853,3 @@ it.effect("expands only files the change request changes, across every slice of assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); }), ); - -it.effect("checks expanded files against a diff too large to cache without fetching it again", () => - Effect.gen(function* () { - let diffReads = 0; - const filler = `+${"x".repeat(300 * 1024)}`; - const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], - providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: () => - Effect.sync(() => { - diffReads += 1; - return { - patch: ["a.ts", "b.ts"] - .flatMap((name) => [ - `diff --git a/${name} b/${name}`, - `--- a/${name}`, - `+++ b/${name}`, - "@@ -0,0 +1 @@", - filler, - ]) - .join("\n"), - truncated: false, - nextCursor: null, - }; - }), - getDiffFileContents: () => Effect.succeed({ oldContents: "old", newContents: "new" }), - }), - ], - }); - const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - for (const name of ["a.ts", "b.ts"]) { - yield* service.diffFileContents({ - ...ref, - changeType: "change", - oldPath: name, - newPath: name, - }); - } - assert.strictEqual(diffReads, 1); - }), -); diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts deleted file mode 100644 index 68321f39eb22..000000000000 --- a/apps/server/src/pullRequest/pullRequestChangeBreakdown.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { toChangeBreakdown } from "./pullRequestChangeBreakdown.ts"; - -describe("toChangeBreakdown", () => { - it("sums line counts per kind", () => { - expect( - toChangeBreakdown({ - truncated: false, - files: [ - { path: "src/a.ts", additions: 10, deletions: 2 }, - { path: "src/b.ts", additions: 5, deletions: 1 }, - { path: "src/a.test.ts", additions: 40, deletions: 0 }, - { path: "pnpm-lock.yaml", additions: 300, deletions: 120 }, - ], - }), - ).toEqual({ - code: { additions: 15, deletions: 3 }, - tests: { additions: 40, deletions: 0 }, - mocks: { additions: 0, deletions: 0 }, - generated: { additions: 300, deletions: 120 }, - }); - }); - - it("gives no breakdown for a partial file list", () => { - expect( - toChangeBreakdown({ - truncated: true, - files: [{ path: "src/a.ts", additions: 1, deletions: 0 }], - }), - ).toBeNull(); - }); -}); diff --git a/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts index 77d8162e8956..eaaf50c0d868 100644 --- a/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts +++ b/apps/server/src/pullRequest/pullRequestChangeBreakdown.ts @@ -5,7 +5,13 @@ import type { ProviderFileStats } from "@t3tools/source-control-core/server/Pull export function toChangeBreakdown(stats: ProviderFileStats): PullRequestChangeBreakdown { if (stats.truncated) return null; const empty = () => ({ additions: 0, deletions: 0 }); - const breakdown = { code: empty(), tests: empty(), mocks: empty(), generated: empty() }; + const breakdown = { + code: empty(), + tests: empty(), + mocks: empty(), + generated: empty(), + docs: empty(), + }; for (const file of stats.files) { const bucket = breakdown[classifyChangedFile(file.path)]; bucket.additions += file.additions; diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts deleted file mode 100644 index 3db791492c6d..000000000000 --- a/apps/server/src/pullRequest/pullRequestReviewGuideInput.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { PullRequestActivity, PullRequestReviewThread } from "@t3tools/contracts"; - -import { settledReviewSummary } from "./pullRequestReviewGuideInput.ts"; - -const thread = ( - overrides: Partial & { body?: string; login?: string }, -): PullRequestReviewThread => ({ - id: overrides.id ?? "t1", - path: overrides.path ?? "src/a.ts", - line: 1, - side: "right", - isResolved: overrides.isResolved ?? false, - isOutdated: overrides.isOutdated ?? false, - comments: [ - { - id: "c1", - author: { login: overrides.login ?? "macroscope[bot]", name: null, avatarUrl: null }, - body: overrides.body ?? "Possible null dereference", - createdAt: "2026-10-01T00:00:00.000Z", - url: null, - }, - ], -}); - -const activity = (reviewThreads: ReadonlyArray): PullRequestActivity => ({ - comments: [], - commentCount: 0, - commentsTruncated: false, - reviewThreads, - commits: [], -}); - -describe("settledReviewSummary", () => { - it("lists resolved and outdated threads and leaves open ones for the guide", () => { - const summary = settledReviewSummary( - activity([ - thread({ id: "open", path: "src/open.ts", body: "Still open" }), - thread({ id: "resolved", path: "src/a.ts", isResolved: true }), - thread({ - id: "outdated", - path: "src/b.ts", - isOutdated: true, - login: "julius", - body: "Rename this \n\nplease", - }), - ]), - ); - - expect(summary).toBe( - [ - "- src/a.ts (resolved, macroscope[bot]): Possible null dereference", - "- src/b.ts (outdated, julius): Rename this please", - ].join("\n"), - ); - }); -}); diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts index 353c10eb5260..2f483490e86e 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideInput.ts @@ -11,7 +11,7 @@ function oneLine(text: string): string { return flat.length > SETTLED_REMARK_CHARS ? `${flat.slice(0, SETTLED_REMARK_CHARS)}…` : flat; } -export function settledReviewSummary(activity: PullRequestActivity): string { +function settledReviewSummary(activity: PullRequestActivity): string { return activity.reviewThreads .filter((thread) => thread.isResolved || thread.isOutdated) .flatMap((thread) => { diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index 233013ac7146..69479b1ccfee 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -121,18 +121,6 @@ describe("normalizeReviewGuide", () => { expect(result.stops).toHaveLength(1); }); - it("keeps a long excerpt to its first 25 lines", () => { - const long = parseReviewGuideChange( - "big.ts\0", - "--- a/big.ts\n+++ b/big.ts\n@@ -0,0 +1,100 @@\n", - ); - const result = normalizeReviewGuide( - guide({ stops: [stop({ excerpts: [excerpt("big.ts", 1, 100)] })] }), - long, - ); - expect(result.stops[0]?.excerpts).toEqual([excerpt("big.ts", 1, 25)]); - }); - it("drops notes outside the stop's remaining excerpts", () => { const result = normalizeReviewGuide( guide({ diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index f172a1fe567e..fc2913e3efd4 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -19,13 +19,13 @@ import type { PullRequestError, SupportedProject } from "./PullRequestService.ts const FILE_REVISIONS_CACHE_TTL = Duration.seconds(60); const FILE_REVISIONS_STALE_WINDOW = Duration.minutes(10); -export const FILE_REVISIONS_CACHE_CAPACITY = 64; +const FILE_REVISIONS_CACHE_CAPACITY = 64; /** * Bounds a single scope, not how many scopes are held: a reader ticking one file after * another renews the same scope and grows it without limit otherwise. */ -export const MAX_FILE_REVISION_PATHS = 1_000; +const MAX_FILE_REVISION_PATHS = 1_000; interface FileRevisionsDependencies { readonly runFork: (effect: Effect.Effect) => unknown; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 3ae05f309ad8..64b71140fdcf 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -11,12 +11,10 @@ import { ProjectId, ProviderInstanceId, RunId, - RuntimeRequestId, ThreadId, TurnItemId, } from "@t3tools/contracts"; import { RelayAgentActivityState } from "@t3tools/contracts/relay"; -import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -26,7 +24,6 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as Tracer from "effect/Tracer"; import * as TestClock from "effect/testing/TestClock"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; @@ -290,45 +287,6 @@ const makeTestRelay = Effect.fnUntraced(function* ( }); describe("AgentAwarenessRelay", () => { - it("ignores transcript and tool updates but retains activity and metadata changes", () => { - for (const type of [ - "message.updated", - "turn-item.updated", - "provider-turn.updated", - "thread.visited", - "thread.pinned", - ] as const) { - assert.isFalse(AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type })); - } - for (const type of [ - "run.created", - "run.updated", - "runtime-request.updated", - "subagent.updated", - "provider-thread.updated", - "thread.metadata-updated", - "thread.model-selection-updated", - "thread.provider-switched", - "thread.archived", - "thread.unarchived", - "thread.deleted", - ] as const) { - assert.isTrue(AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type })); - } - }); - - it("does not publish imported thread creation as new agent activity", () => { - assert.isFalse( - AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ - type: "thread.created", - payload: { historyOrigin: "v1_import" }, - }), - ); - assert.isTrue( - AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ type: "thread.created", payload: {} }), - ); - }); - it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () => Effect.gen(function* () { const started = yield* Deferred.make(); @@ -376,29 +334,6 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("exports the publication span through the relay tracer", () => { - const relaySpans: string[] = []; - const localSpans: string[] = []; - const collectingTracer = (spans: string[]) => - Tracer.make({ - span: (options) => { - spans.push(options.name); - return new Tracer.NativeSpan(options); - }, - }); - - return Effect.gen(function* () { - const { relay, publications } = yield* makeTestRelay(); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - assert.include(relaySpans, "relay.agent_activity.publish"); - assert.notInclude(localSpans, "relay.agent_activity.publish"); - }).pipe( - Effect.provideService(RelayClientTracer, Option.some(collectingTracer(relaySpans))), - Effect.withTracer(collectingTracer(localSpans)), - ); - }); - it.effect("deduplicates state and republishes title changes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -416,22 +351,6 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("stops before shell reads when disabled and republishes after re-enabling", () => - Effect.gen(function* () { - const { relay, secrets, secretReads, shellReads, publications } = yield* makeTestRelay(); - yield* relay.publishThread(THREAD_ID); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); - const previousReads = secretReads.length; - yield* relay.publishThread(THREAD_ID); - assert.deepEqual(secretReads.slice(previousReads), [PUBLISH_AGENT_ACTIVITY_SECRET]); - assert.equal(shellReads.length, 1); - assert.equal(publications.length, 1); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - }), - ); - it.effect("republishes unchanged state with fresh credentials after relinking", () => Effect.gen(function* () { const { relay, secrets, publications } = yield* makeTestRelay(); @@ -454,326 +373,8 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("a stalled response body releases later threads and uses bounded retries", () => - Effect.gen(function* () { - const bodyStarted = Promise.withResolvers(); - const secondRead = yield* Deferred.make(); - const currentShell = yield* Ref.make(shell()); - const { relay, publications } = yield* makeTestRelay({ - readShell: (threadId) => - threadId === SECOND_THREAD_ID - ? Deferred.succeed(secondRead, undefined).pipe( - Effect.as(shell({ id: SECOND_THREAD_ID })), - ) - : Ref.get(currentShell), - respond: (attempt) => { - if (attempt !== 1) return new Response("relay unavailable", { status: 503 }); - const response = new Response(null, { - headers: { "content-type": "application/json" }, - }); - return Object.assign(response, { - arrayBuffer: () => { - bodyStarted.resolve(); - return new Promise(() => {}); - }, - }); - }, - }); - const first = yield* relay.publishThread(THREAD_ID).pipe(Effect.forkChild); - yield* Effect.promise(() => bodyStarted.promise); - const second = yield* relay.publishThread(SECOND_THREAD_ID).pipe(Effect.forkChild); - yield* TestClock.adjust("9999 millis"); - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, shell({ title: "Latest title" })); - yield* TestClock.adjust("1 millis"); - assert.isTrue(yield* Deferred.isDone(secondRead)); - yield* Fiber.join(first); - yield* Fiber.join(second); - assert.equal(publications.length, 2); - assert.isTrue(publications[1]?.url.includes(SECOND_THREAD_ID) ?? false); - for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { - const attempts = publications.length; - yield* TestClock.adjust(delay - 1); - yield* relay.drain; - assert.equal(publications.length, attempts); - yield* TestClock.adjust(1); - yield* relay.drain; - assert.equal(publications.length, attempts + 2); - assert.equal(publications[attempts]?.state?.threadTitle, "Latest title"); - } - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 12); - }), - ); - - it.effect("retries a failed final state without another thread event", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 2 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 3); - assert.equal(publications[2]?.state?.phase, "completed"); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(shellReads.length, 3); - }), - ); - - it.effect("retries the latest shell and credentials with first-completion confirmation", () => - Effect.gen(function* () { - const { relay, secrets, currentShell, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 1 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - title: "Final title", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* secrets.set( - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - new TextEncoder().encode("credential-2"), - ); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 1); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.authorization, "Bearer credential-2"); - assert.equal(publications[1]?.state?.phase, "completed"); - assert.equal(publications[1]?.state?.threadTitle, "Final title"); - }), - ); - - it.effect("cancels a stale retry when a newer update publishes successfully", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt === 1 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell({ title: "New title" })); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state?.threadTitle, "New title"); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(shellReads.length, 2); - }), - ); - - it.effect.each(["disable", "unlink"] as const)( - "stops retries before shell reads after %s", - (change) => - Effect.gen(function* () { - const { relay, secrets, shellReads, publications } = yield* makeTestRelay({ - respond: () => new Response("relay unavailable", { status: 503 }), - }); - yield* relay.publishThread(THREAD_ID); - if (change === "disable") { - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); - } else { - yield* secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); - } - yield* TestClock.adjust("1 second"); - yield* relay.drain; - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 1); - assert.equal(shellReads.length, 1); - }), - ); - - it.effect("recovers from a transient configuration read failure", () => - Effect.gen(function* () { - let failRead = false; - const { relay, shellReads, publications } = yield* makeTestRelay({ - failSecretRead: (name) => failRead && name === PUBLISH_AGENT_ACTIVITY_SECRET, - }); - failRead = true; - yield* relay.publishThread(THREAD_ID); - assert.equal(shellReads.length, 0); - failRead = false; - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 1); - assert.equal(publications[0]?.state?.phase, "running"); - }), - ); - - it.effect("bounds retry attempts and resets the budget for a newer update", () => - Effect.gen(function* () { - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: () => new Response("relay unavailable", { status: 503 }), - }); - yield* relay.publishThread(THREAD_ID); - let attempts = 1; - for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { - yield* TestClock.adjust(delay - 1); - yield* relay.drain; - assert.equal(publications.length, attempts); - yield* TestClock.adjust(1); - yield* relay.drain; - attempts += 1; - assert.equal(publications.length, attempts); - } - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 6); - assert.equal(shellReads.length, 6); - yield* Ref.set(currentShell, shell({ title: "New update" })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 8); - assert.equal(publications[7]?.state?.threadTitle, "New update"); - }), - ); - - it.effect("retries a confirmed completion without restarting its confirmation window", () => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay({ - respond: (attempt) => - attempt <= 2 - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 1); - yield* TestClock.adjust("1 second"); - yield* relay.drain; - assert.equal(publications.length, 2); - yield* TestClock.adjust("2 seconds"); - yield* relay.drain; - assert.equal(publications.length, 3); - assert.isTrue(publications.every((publication) => publication.state?.phase === "completed")); - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, 3); - }), - ); - - it.effect("does not reset an exhausted retry budget for old confirmation timers", () => - Effect.gen(function* () { - let unavailable = false; - const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ - respond: () => - unavailable - ? new Response("relay unavailable", { status: 503 }) - : Response.json({ ok: true, deliveries: [] }), - }); - yield* relay.publishThread(THREAD_ID); - for (let i = 0; i < 6; i++) { - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell()); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust(1); - } - unavailable = true; - yield* Ref.set(currentShell, shell({ status: "failed" })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust(1_000); - yield* relay.drain; - yield* TestClock.adjust(2_000); - yield* relay.drain; - yield* TestClock.adjust(1_994); - yield* relay.drain; - for (let i = 0; i < 5; i++) { - yield* TestClock.adjust(1); - yield* relay.drain; - } - const attempts = publications.length; - const reads = shellReads.length; - yield* TestClock.adjust("1 minute"); - yield* relay.drain; - assert.equal(publications.length, attempts); - assert.equal(shellReads.length, reads); - }), - ); - - it.effect("confirms tombstones and cancels stale confirmations when activity recovers", () => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, shell()); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 1); - yield* Ref.set(currentShell, null); - yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 1); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state, null); - }), - ); - - it.effect.each([ - { label: "live", archived: false }, - { label: "archived", archived: true }, - ])("never publishes tombstones for $label subagent threads", ({ archived }) => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set( - currentShell, - shell({ - lineage: { - rootThreadId: THREAD_ID, - parentThreadId: THREAD_ID, - relationshipToParent: "subagent", - }, - ...(archived ? { archivedAt: yield* DateTime.now } : {}), - }), - ); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.equal(publications.length, 0); - }), - ); - it.effect.each(["completed", "failed"] as const)( - "settling a %s thread publishes a tombstone without re-alerting on unsettle", + "publishes a tombstone when a %s thread is settled through a domain event", (status) => Effect.gen(function* () { const events = yield* Queue.unbounded(); @@ -785,124 +386,59 @@ describe("AgentAwarenessRelay", () => { }); yield* relay.start(); yield* Queue.take(pulls); - const finished = shell({ - status, - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }); - yield* Ref.set(currentShell, finished); yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase), - [status], - ); - - for (const type of ["thread.settled", "thread.unsettled"] as const) { - const updated = { - ...finished, - settledOverride: type === "thread.settled" ? ("settled" as const) : ("active" as const), - updatedAt: yield* DateTime.now, - }; - yield* Ref.set(currentShell, updated); - yield* Queue.offer(events, { - id: EventId.make(type), - type, - threadId: THREAD_ID, - occurredAt: updated.updatedAt, - payload: { ...updated, lastVisitedAt: updated.lastVisitedAt ?? null }, - }); - yield* Queue.take(pulls); - yield* relay.drain; - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase ?? null), - [status, null], - ); - } - }).pipe(Effect.scoped), - ); - - it.effect.each([ - { work: "running", status: "running", phase: "running" }, - { work: "user input", status: "completed", phase: "waiting_for_input" }, - { work: "monitor", status: "completed", phase: "running" }, - { work: "monitor", status: "failed", phase: "failed" }, - ] as const)( - "keeps settled $work visible until it finishes ($status run)", - ({ work, status, phase }) => - Effect.gen(function* () { - const { relay, currentShell, publications } = yield* makeTestRelay(); - const active = shell({ - status, - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - pendingRuntimeRequest: - work === "user input" - ? { - id: RuntimeRequestId.make("waiting"), - kind: "user_input", - createdAt: yield* DateTime.now, - } - : null, - pendingBackgroundTasks: work === "monitor" ? [{ taskId: "watch", kind: "monitor" }] : [], - }); - yield* Ref.set(currentShell, active); + const terminal = shell({ status }); + yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase), - [phase], - ); - - yield* Ref.set(currentShell, { ...active, settledOverride: "settled" }); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase), - [phase], - ); + assert.equal(publications[1]?.state?.phase, status); - yield* Ref.set(currentShell, { - ...active, - settledOverride: "settled", - status: status === "running" ? "completed" : status, - pendingRuntimeRequest: null, - pendingBackgroundTasks: [], + const now = yield* DateTime.now; + const settled = { + ...terminal, + settledOverride: "settled" as const, + settledAt: now, + lastVisitedAt: null, + }; + yield* Ref.set(currentShell, settled); + yield* Queue.offer(events, { + id: EventId.make("event:settled"), + type: "thread.settled", + threadId: THREAD_ID, + occurredAt: now, + payload: settled, }); - yield* relay.publishThread(THREAD_ID); + yield* Queue.take(pulls); + yield* relay.drain; + assert.equal(publications.length, 2); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.deepEqual( - publications.map(({ state }) => state?.phase ?? null), - [phase, null], - ); - }), + assert.equal(publications.length, 3); + assert.equal(publications[2]?.state, null); + }).pipe(Effect.scoped), ); - it.effect("confirms a first completed state and respects disabling during confirmation", () => + it.effect("keeps settled live work until its monitor finishes", () => Effect.gen(function* () { - const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set( - currentShell, - shell({ - status: "completed", - latestRunCompletedAt: DateTime.add(yield* DateTime.now, { seconds: 1 }), - }), - ); + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const held = shell({ + status: "completed", + settledOverride: "settled", + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + }); + yield* Ref.set(currentShell, held); yield* relay.publishThread(THREAD_ID); - assert.equal(publications.length, 0); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.equal(publications[0]?.state?.phase, "completed"); - yield* Ref.set(currentShell, null); + assert.equal(publications.length, 1); + assert.equal(publications[0]?.state?.phase, "running"); + + yield* Ref.set(currentShell, { ...held, pendingBackgroundTasks: [] }); yield* relay.publishThread(THREAD_ID); - yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.equal(publications.length, 1); + assert.equal(publications.length, 2); + assert.equal(publications[1]?.state, null); }), ); it.effect("publishes a held completion when its background item ends, not on tool output", () => @@ -1064,69 +600,3 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped, Effect.provide(ProjectionStore.layerMemory)), ); }); - -describe("startup catch-up", { concurrent: false }, () => { - const link = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => - Effect.all( - [ - secrets.set(RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")), - secrets.set(RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")), - secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")), - ], - { discard: true }, - ); - const enablePublishing = (secrets: ServerSecretStore.ServerSecretStore["Service"]) => - secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); - const linkChecks = (secretReads: ReadonlyArray) => - secretReads.filter((name) => name === RELAY_URL_SECRET).length; - - it.effect("checks an unlinked environment once a minute and still catches up once linked", () => - Effect.gen(function* () { - const { relay, secrets, secretReads, catchUp } = yield* makeTestRelay({ linked: false }); - yield* enablePublishing(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - const checksBeforeWindow = linkChecks(secretReads); - yield* TestClock.adjust("10 minutes"); - assert.equal(linkChecks(secretReads) - checksBeforeWindow, 10); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* link(secrets); - yield* TestClock.adjust("1 minute"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); - - it.effect("publishes at once when this process links while the check is backed off", () => - Effect.gen(function* () { - const { relay, secrets, catchUp } = yield* makeTestRelay({ linked: false }); - yield* enablePublishing(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - yield* link(secrets); - yield* TestClock.adjust("1 second"); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* relay.requestCatchUp(); - yield* TestClock.adjust("1 second"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); - - it.effect("catches up within 5 s when another process enables publishing on a link", () => - Effect.gen(function* () { - const { relay, secrets, catchUp } = yield* makeTestRelay({ linked: false }); - yield* link(secrets); - yield* relay.start(); - - yield* TestClock.adjust("10 minutes"); - assert.equal(catchUp.shellSnapshotReads, 0); - - yield* enablePublishing(secrets); - yield* TestClock.adjust("5 seconds"); - assert.equal(catchUp.shellSnapshotReads, 1); - }).pipe(Effect.scoped), - ); -}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 07c308b033d7..846c9a94f1fc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -73,7 +73,7 @@ function isTurnItemPayload( ); } -export function shouldPublishAgentAwarenessEvent( +function shouldPublishAgentAwarenessEvent( event: Pick & { readonly payload?: unknown }, ): boolean { if ( diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts index 50c8ea2b7632..616643c6e1e9 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.test.ts @@ -1,94 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; import { it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import { assert, describe, expect } from "vite-plus/test"; +import { describe, expect } from "vite-plus/test"; -import { - type DesktopTelemetryReceiverHealth, - initialDesktopTelemetryContactAt, - isDesktopTelemetryContactStale, - recordDesktopTelemetrySampleHealth, - requireDesktopTelemetryWriteProgress, - resolveDesktopTelemetrySnapshotStaleAfterMs, - writeAllToFileDescriptor, -} from "./DesktopTelemetryReceiver.ts"; +import { requireDesktopTelemetryWriteProgress } from "./DesktopTelemetryReceiver.ts"; describe("DesktopTelemetryReceiver", () => { - it("degrades a hello-only stream after the first-sample deadline", () => { - expect(isDesktopTelemetryContactStale(Option.some(1_000), 90_999)).toBe(false); - expect(isDesktopTelemetryContactStale(Option.some(1_000), 91_000)).toBe(true); - expect(isDesktopTelemetryContactStale(Option.none(), 1_000_000)).toBe(false); - }); - - it("starts the stale deadline as soon as a telemetry descriptor is opened", () => { - expect(initialDesktopTelemetryContactAt(7, 1_000)).toEqual(Option.some(1_000)); - expect(initialDesktopTelemetryContactAt(undefined, 1_000)).toEqual(Option.none()); - }); - - it("keeps the snapshot deadline beyond the configured idle polling interval", () => { - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(30_000, 120_000)).toBe(150_000); - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(60_000, 600_000)).toBe(630_000); - expect(resolveDesktopTelemetrySnapshotStaleAfterMs(1_000, 1_000)).toBe(90_000); - }); - - it.effect("publishes the latest sample timestamp while health remains healthy", () => - Effect.scoped( - Effect.gen(function* () { - const initialSample = DateTime.makeUnsafe(1_000); - const nextSample = DateTime.makeUnsafe(2_000); - const health = yield* Ref.make({ - status: "healthy", - lastSampleAt: Option.some(initialSample), - lastError: Option.none(), - }); - const healthChanges = yield* PubSub.sliding(4); - const subscription = yield* PubSub.subscribe(healthChanges); - - yield* recordDesktopTelemetrySampleHealth(health, healthChanges, nextSample); - const published = yield* PubSub.take(subscription).pipe(Effect.timeout("1 second")); - - expect(DateTime.toEpochMillis(Option.getOrThrow(published.lastSampleAt))).toBe(2_000); - }), - ), - ); - - it.effect("writes control messages through the asynchronous descriptor path", () => - Effect.acquireUseRelease( - Effect.sync(() => { - const directory = NodeFS.mkdtempSync( - NodePath.join(NodeOS.tmpdir(), "t3-desktop-telemetry-control-test-"), - ); - const path = NodePath.join(directory, "control.ndjson"); - return { - directory, - path, - fd: NodeFS.openSync(path, "w"), - }; - }), - ({ fd, path }) => - Effect.gen(function* () { - const payload = Buffer.from('{"type":"setDiagnosticsDemand","enabled":true}\n'); - yield* writeAllToFileDescriptor(fd, payload); - NodeFS.fsyncSync(fd); - - assert.equal(NodeFS.readFileSync(path, "utf8"), payload.toString("utf8")); - }), - ({ directory, fd }) => - Effect.sync(() => { - NodeFS.closeSync(fd); - NodeFS.rmSync(directory, { recursive: true, force: true }); - }), - ), - ); - it.effect("models a zero-byte control write as a stalled descriptor", () => Effect.gen(function* () { const error = yield* requireDesktopTelemetryWriteProgress(7, 42, 0).pipe(Effect.flip); diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index a911c2b05d3d..ddd31817c4ce 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -202,7 +202,7 @@ const isProtocolMismatch = Schema.is(DesktopTelemetryProtocolMismatch); const isDecodeFailed = Schema.is(DesktopTelemetryDecodeFailed); const isStreamFailed = Schema.is(DesktopTelemetryStreamFailed); -export function isDesktopTelemetryContactStale( +function isDesktopTelemetryContactStale( lastContactAtMs: Option.Option, nowMs: number, ): boolean { @@ -212,7 +212,7 @@ export function isDesktopTelemetryContactStale( ); } -export function resolveDesktopTelemetrySnapshotStaleAfterMs( +function resolveDesktopTelemetrySnapshotStaleAfterMs( activeIntervalMs: number, idleIntervalMs: number, ): number { @@ -222,14 +222,14 @@ export function resolveDesktopTelemetrySnapshotStaleAfterMs( ); } -export function initialDesktopTelemetryContactAt( +function initialDesktopTelemetryContactAt( desktopTelemetryFd: number | undefined, nowMs: number, ): Option.Option { return desktopTelemetryFd === undefined ? Option.none() : Option.some(nowMs); } -export const recordDesktopTelemetrySampleHealth = Effect.fn( +const recordDesktopTelemetrySampleHealth = Effect.fn( "resourceTelemetry.desktopTelemetryReceiver.recordSampleHealth", )(function* ( health: Ref.Ref, diff --git a/apps/server/src/resourceTelemetry/Model.test.ts b/apps/server/src/resourceTelemetry/Model.test.ts index cbc0dc90848f..01b73f73c9d4 100644 --- a/apps/server/src/resourceTelemetry/Model.test.ts +++ b/apps/server/src/resourceTelemetry/Model.test.ts @@ -491,45 +491,6 @@ describe("resource telemetry process model", () => { expect(second.groups.backend.ioWriteBytes).toBe(3_000); }); - it("derives deltas at the constrained 15-second sampling cadence", () => { - const first = merge({ - native: nativeSnapshot(BASE_TIME_MS, [ - processSample({ - pid: SERVER_PID, - ppid: 1, - startTimeMs: 1_000, - cpuTimeMs: 1_000, - ioReadBytes: 10_000, - ioWriteBytes: 20_000, - }), - ]), - }); - const second = merge({ - previous: first, - native: nativeSnapshot( - BASE_TIME_MS + 15_000, - [ - processSample({ - pid: SERVER_PID, - ppid: 1, - startTimeMs: 1_000, - cpuTimeMs: 2_500, - ioReadBytes: 25_000, - ioWriteBytes: 50_000, - }), - ], - 2, - ), - }); - - expect(second.processes[0]?.cpuPercent).toBe(10); - expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000); - expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000); - expect(second.groups.backend.cpuTimeMs).toBe(1_500); - expect(second.groups.backend.ioReadBytes).toBe(15_000); - expect(second.groups.backend.ioWriteBytes).toBe(30_000); - }); - it("preserves native rates while applying a desktop-only update", () => { const first = merge({ native: nativeSnapshot(BASE_TIME_MS, [ diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 44bb9c7c2002..46af99cb9dfd 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,36 +1,14 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import type { HostPowerSnapshot } from "@t3tools/contracts"; -import { - RESOURCE_MONITOR_PROTOCOL_VERSION, - ResourceMonitorCommand, - type ResourceMonitorHelloEvent, -} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; -import * as ServerConfig from "../config.ts"; -import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; -import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; import { - canCommandNativeTelemetrySidecar, - canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - retainRecentNativeTelemetryFailures, - resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, } from "./NativeTelemetryClient.ts"; @@ -47,142 +25,6 @@ const basePower: HostPowerSnapshot = { updatedAt: DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"), }; -describe("resolveNativeSampleIntervalMs", () => { - it("keeps a recovery cadence while suspended and backs off under host constraints", () => { - expect(resolveNativeSampleIntervalMs({ ...basePower, suspended: true }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, lowPowerMode: "true" }, 1)).toBe(15_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, thermalState: "critical" }, 1)).toBe( - 15_000, - ); - expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); - }); - - it("stops timed sampling without a live subscriber and serves live diagnostics at 1Hz", () => { - const unknown: HostPowerSnapshot = { - ...basePower, - source: "unknown", - stale: true, - }; - expect(resolveNativeSampleIntervalMs(unknown, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(unknown, 1)).toBe(1_000); - expect(resolveNativeSampleIntervalMs({ ...basePower, locked: "true" }, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(0); - expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); - }); -}); - -const decodeCommandLine = Schema.decodeUnknownSync(Schema.fromJsonString(ResourceMonitorCommand)); - -const hello: ResourceMonitorHelloEvent = { - version: RESOURCE_MONITOR_PROTOCOL_VERSION, - type: "hello", - sidecarVersion: "test", - sidecarPid: 4242, - platform: "linux", - arch: "x64", - capabilities: { - cumulativeCpuTime: true, - currentCpuPercent: true, - residentMemory: true, - virtualMemory: true, - ioBytes: true, - processStartTime: true, - processTree: true, - }, -}; - -describe("NativeTelemetryClient", () => { - it.effect("asks the sidecar for timed samples only while a live subscriber exists", () => - Effect.gen(function* () { - const commands = yield* Queue.unbounded(); - const spawner = ChildProcessSpawner.make(() => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(hello.sidecarPid), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((chunk: Uint8Array) => - Effect.forEach(new TextDecoder().decode(chunk).split("\n").filter(Boolean), (line) => - Queue.offer(commands, decodeCommandLine(line)), - ), - ), - stdout: Stream.encodeText(Stream.make(`${JSON.stringify(hello)}\n`)).pipe( - Stream.concat(Stream.never), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ), - ); - const client = yield* NativeTelemetryClient.make().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorBinary, { - resolve: Effect.succeed("/test/t3-resource-monitor"), - }), - ); - - const configure = yield* Queue.take(commands); - expect(configure).toMatchObject({ type: "configure", sampleIntervalMs: 0 }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setExternalProcesses" }); - - const live = yield* Scope.make(); - yield* client.snapshots.pipe(Stream.runDrain, Effect.forkIn(live)); - expect(yield* Queue.take(commands)).toMatchObject({ - type: "setSampleInterval", - sampleIntervalMs: 1_000, - }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setStreaming", enabled: true }); - - yield* Scope.close(live, Exit.void); - expect(yield* Queue.take(commands)).toMatchObject({ - type: "setSampleInterval", - sampleIntervalMs: 0, - }); - expect(yield* Queue.take(commands)).toMatchObject({ type: "setStreaming", enabled: false }); - expect((yield* client.health).sampleIntervalMs).toBe(0); - }).pipe( - Effect.provide( - Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-native-telemetry-test-" }), - NodeCrypto.layer, - ).pipe(Layer.provideMerge(NodeServices.layer)), - ), - ), - ); -}); - -describe("canRequestNativeTelemetryRetry", () => { - it("only accepts retry while the supervisor is waiting without a live sidecar", () => { - expect(canRequestNativeTelemetryRetry("degraded", false)).toBe(true); - expect(canRequestNativeTelemetryRetry("unavailable", false)).toBe(true); - expect(canRequestNativeTelemetryRetry("degraded", true)).toBe(false); - expect(canRequestNativeTelemetryRetry("healthy", false)).toBe(false); - expect(canRequestNativeTelemetryRetry("starting", false)).toBe(false); - }); -}); - -describe("canCommandNativeTelemetrySidecar", () => { - it("keeps on-demand recovery commands available while a live sidecar is degraded", () => { - expect(canCommandNativeTelemetrySidecar("healthy", true)).toBe(true); - expect(canCommandNativeTelemetrySidecar("degraded", true)).toBe(true); - expect(canCommandNativeTelemetrySidecar("unavailable", true)).toBe(false); - expect(canCommandNativeTelemetrySidecar("degraded", false)).toBe(false); - }); -}); - -describe("retainRecentNativeTelemetryFailures", () => { - it("expires old failures so an isolated crash restarts from the initial backoff", () => { - expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); - expect(retainRecentNativeTelemetryFailures([30_000, 60_000], 90_000)).toEqual([30_000, 60_000]); - }); -}); - describe("commitCollectionControlUpdate", () => { it.effect("retains desired demand and retries unapplied sidecar state", () => Effect.gen(function* () { diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 5ec3664bf214..6f4a019a8ac7 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -261,7 +261,7 @@ function isThermallyConstrained(snapshot: HostPowerSnapshot): boolean { // Without a live subscriber the sidecar does not sample on a timer, so an idle server // never walks the process table. History then covers only time with a subscriber. -export function resolveNativeSampleIntervalMs( +function resolveNativeSampleIntervalMs( snapshot: HostPowerSnapshot, liveSubscriberCount: number, ): number { @@ -336,7 +336,7 @@ function restartDelay(attempt: number): Duration.Duration { return Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); } -export function retainRecentNativeTelemetryFailures( +function retainRecentNativeTelemetryFailures( failures: ReadonlyArray, now: number, ): ReadonlyArray { @@ -347,14 +347,14 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function canRequestNativeTelemetryRetry( +function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, ): boolean { return status !== "healthy" && status !== "starting" && !hasHandle; } -export function canCommandNativeTelemetrySidecar( +function canCommandNativeTelemetrySidecar( status: ResourceTelemetrySourceStatus, hasHandle: boolean, ): boolean { diff --git a/apps/server/src/resourceTelemetry/ResourceAttribution.ts b/apps/server/src/resourceTelemetry/ResourceAttribution.ts index 374564bb7d88..a8943a35f32d 100644 --- a/apps/server/src/resourceTelemetry/ResourceAttribution.ts +++ b/apps/server/src/resourceTelemetry/ResourceAttribution.ts @@ -32,7 +32,7 @@ function nonNegativeInteger(value: number | undefined, fallback: number): number return Math.max(0, Math.round(value)); } -export const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { +const make = Effect.fn("resourceTelemetry.resourceAttribution.make")(function* () { const entries = yield* Ref.make(new Map()); const record: ResourceAttribution["Service"]["record"] = (input) => diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 1f30be947718..c79b7f466f12 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; +import { afterEach, assert, describe, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -16,56 +16,6 @@ describe("ResourceMonitorBinary", () => { vi.restoreAllMocks(); }); - it.effect("skips Linux libc detection on Windows", () => - Effect.gen(function* () { - const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { - throw new Error("Linux libc detection must not run on Windows"); - }); - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor.exe`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provideService(HostProcess.Architecture, "arm64"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - expect(getReport).not.toHaveBeenCalled(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect.skipIf(windowsHost)("resolves an executable override", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const binaryPath = `${baseDir}/t3-resource-monitor`; - yield* fileSystem.writeFileString(binaryPath, "binary"); - yield* fileSystem.chmod(binaryPath, 0o755); - - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(HostProcess.Architecture, "x64"), - Effect.provideService(ResourceMonitorBinary.ResourceMonitorHostLinuxLibc, "musl"), - Effect.provideService(HostProcess.Environment, { - T3CODE_RESOURCE_MONITOR_PATH: binaryPath, - }), - ); - - assert.equal(yield* service.resolve, binaryPath); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect.skipIf(windowsHost)("resolves an executable override on an unsupported platform", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -115,6 +65,7 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + // regression: #16838 a directory resolved as the resource monitor binary it.effect("rejects a directory override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -137,24 +88,6 @@ describe("ResourceMonitorBinary", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("rejects unsupported platform and architecture pairs", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-resource-monitor-binary-", - }); - const service = yield* ResourceMonitorBinary.make().pipe( - Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), - Effect.provideService(HostProcess.Platform, "freebsd"), - Effect.provideService(HostProcess.Architecture, "ia32"), - Effect.provideService(HostProcess.Environment, {}), - ); - const error = yield* Effect.flip(service.resolve); - - assert.instanceOf(error, ResourceMonitorBinary.ResourceMonitorBinaryUnsupported); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("rejects bundled glibc binaries on musl Linux hosts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts index 37c3115ddc43..b8a982d80ed6 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -1,3 +1,5 @@ +import * as Duration from "effect/Duration"; +import * as TestClock from "effect/testing/TestClock"; import type { DesktopHostTelemetrySnapshot, ResourceMonitorProcessSample, @@ -6,7 +8,6 @@ import type { import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -14,7 +15,6 @@ import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as DesktopTelemetryReceiver from "./DesktopTelemetryReceiver.ts"; import * as NativeTelemetryClient from "./NativeTelemetryClient.ts"; @@ -216,87 +216,6 @@ describe("ResourceTelemetry", () => { }), ); - it.effect("enables live native and Electron collection only while changes are retained", () => - Effect.gen(function* () { - const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); - const sample = nativeSnapshot({ - sequence: 1, - sampledAtUnixMs, - childCpuTimeMs: 100, - childWriteBytes: 1_000, - }); - const demandChanges = yield* Ref.make>([]); - const layerNative = NativeTelemetryClient.layerTest({ - sampleNow: Effect.succeed(nativeGeneration(sample, 0)), - health: Effect.succeed({ - status: "healthy", - hello: Option.none(), - lastSampleAt: Option.none(), - lastError: Option.none(), - restartCount: 0, - sampleIntervalMs: 1_000, - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), - setDiagnosticsDemand: (enabled) => - Ref.update(demandChanges, (changes) => [...changes, enabled]), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide(Layer.mergeAll(layerNative, layerDesktop, ResourceAttribution.layer)), - ); - - const live = yield* Stream.runHead( - Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - return telemetry.changes; - }).pipe(Stream.unwrap), - ).pipe(Effect.provide(layerTelemetry)); - - expect(Option.isSome(live)).toBe(true); - expect(yield* Ref.get(demandChanges)).toEqual([true, false]); - }), - ); - - it.effect("releases live demand when acquisition is interrupted by an idle collector", () => - Effect.gen(function* () { - const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); - const demandChanges = yield* Ref.make>([]); - const layerNative = NativeTelemetryClient.layerTest({ - sampleNow: Effect.never, - health: Effect.succeed({ - status: "healthy", - hello: Option.none(), - lastSampleAt: Option.none(), - lastError: Option.none(), - restartCount: 0, - sampleIntervalMs: 1_000, - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(sampledAtUnixMs)), - setDiagnosticsDemand: (enabled) => - Ref.update(demandChanges, (changes) => [...changes, enabled]), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide(Layer.mergeAll(layerNative, layerDesktop, ResourceAttribution.layer)), - ); - - const resultFiber = yield* Stream.runHead( - Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - return telemetry.changes; - }).pipe(Stream.unwrap), - ).pipe(Effect.timeoutOption("10 millis"), Effect.provide(layerTelemetry), Effect.forkChild); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - const result = yield* Fiber.join(resultFiber); - - expect(Option.isNone(result)).toBe(true); - expect(yield* Ref.get(demandChanges)).toEqual([true, false]); - }), - ); - it.effect( "attributes an initial Electron root from the identity recorded by the native snapshot", () => @@ -405,73 +324,6 @@ describe("ResourceTelemetry", () => { ), ); - it.effect("retains desktop health changes while aggregate telemetry initializes", () => - Effect.scoped( - Effect.gen(function* () { - const health = yield* Ref.make({ - status: "starting", - lastSampleAt: Option.none(), - lastError: Option.none(), - }); - const healthChanges = - yield* PubSub.sliding(4); - const healthSubscribed = yield* Deferred.make(); - const finishDesktopSnapshot = yield* Deferred.make(); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - health: Ref.get(health), - subscribeHealth: Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(healthChanges); - const latest = yield* Ref.get(health); - yield* Deferred.succeed(healthSubscribed, undefined); - return { - latest, - changes: Stream.fromSubscription(subscription), - }; - }), - subscribe: Deferred.await(finishDesktopSnapshot).pipe( - Effect.as({ - latest: Option.none(), - changes: Stream.empty, - }), - ), - }); - const layerTelemetry = ResourceTelemetry.layer.pipe( - Layer.provide( - Layer.mergeAll( - NativeTelemetryClient.layerTest(), - layerDesktop, - ResourceAttribution.layer, - ), - ), - ); - const resultFiber = yield* Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - while (true) { - const snapshot = yield* telemetry.latest; - if (snapshot.health.desktop.status === "degraded") return snapshot; - yield* Effect.yieldNow; - } - }).pipe(Effect.provide(layerTelemetry), Effect.timeout("1 second"), Effect.forkChild); - - yield* Deferred.await(healthSubscribed); - const degraded: DesktopTelemetryReceiver.DesktopTelemetryReceiverHealth = { - status: "degraded", - lastSampleAt: Option.none(), - lastError: Option.some("desktop telemetry failed"), - }; - yield* Ref.set(health, degraded); - yield* PubSub.publish(healthChanges, degraded); - yield* Deferred.succeed(finishDesktopSnapshot, undefined); - - const snapshot = yield* Fiber.join(resultFiber); - expect(snapshot.health.desktop.status).toBe("degraded"); - expect(Option.getOrNull(snapshot.health.desktop.lastError)).toBe( - "desktop telemetry failed", - ); - }), - ), - ); - it.effect("atomically subscribes while a desktop update is being rebuilt", () => Effect.scoped( Effect.gen(function* () { @@ -540,69 +392,6 @@ describe("ResourceTelemetry", () => { ), ); - it.effect("summarizes usage from background history and shares one read across callers", () => - Effect.gen(function* () { - yield* TestClock.adjust(Duration.minutes(1)); - const now = DateTime.toEpochMillis(yield* DateTime.now); - const externalProcesses = [{ pid: 5_000, startTimeMs: 300 }]; - const historyReads = yield* Ref.make(0); - const status = - yield* Ref.make("healthy"); - const nativeLayer = NativeTelemetryClient.layerTest({ - readHistory: () => - Ref.update(historyReads, (count) => count + 1).pipe( - Effect.as([ - nativeSnapshot({ - sequence: 1, - sampledAtUnixMs: now - 2_000, - childCpuTimeMs: 0, - childWriteBytes: 0, - externalProcesses, - }), - nativeSnapshot({ - sequence: 2, - sampledAtUnixMs: now - 1_000, - childCpuTimeMs: 0, - childWriteBytes: 0, - externalProcesses, - }), - ]), - ), - health: Effect.map(Ref.get(status), (current) => ({ - status: current, - hello: Option.none(), - lastSampleAt: Option.none(), - lastError: Option.none(), - restartCount: 0, - sampleIntervalMs: 5_000, - })), - }); - const layer = ResourceTelemetry.layer.pipe( - Layer.provide( - Layer.mergeAll( - nativeLayer, - DesktopTelemetryReceiver.layerTest(), - ResourceAttribution.layer, - ), - ), - ); - - yield* Effect.gen(function* () { - const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - const [first, second] = yield* Effect.all([telemetry.readUsage, telemetry.readUsage]); - expect(first).toEqual(second); - expect(first?.processCount).toBe(4); - expect(first?.rssBytes).toBe(4 * 1_024); - expect(yield* Ref.get(historyReads)).toBe(1); - - yield* Ref.set(status, "unavailable"); - yield* TestClock.adjust(Duration.seconds(6)); - expect(yield* telemetry.readUsage).toBeNull(); - expect(yield* Ref.get(historyReads)).toBe(1); - }).pipe(Effect.provide(layer)); - }), - ); - it.effect("samples on demand when an idle sidecar has no recent history", () => Effect.gen(function* () { yield* TestClock.adjust(Duration.minutes(1)); @@ -657,181 +446,65 @@ describe("ResourceTelemetry", () => { }), ); - it.effect("combines native, Electron, attribution, retry, and history data", () => + it.effect("summarizes usage from background history and shares one read across callers", () => Effect.gen(function* () { - const startedAt = DateTime.toEpochMillis(yield* DateTime.now); - const samples = [ - nativeSnapshot({ - sequence: 1, - sampledAtUnixMs: startedAt, - childCpuTimeMs: 100, - childWriteBytes: 1_000, - }), - nativeSnapshot({ - sequence: 2, - sampledAtUnixMs: startedAt + 1_000, - childCpuTimeMs: 350, - childWriteBytes: 5_000, - }), - nativeSnapshot({ - sequence: 1, - sampledAtUnixMs: startedAt + 2_000, - childCpuTimeMs: 500, - childWriteBytes: 7_000, - }), - ] as const; - const sampleIndex = yield* Ref.make(0); - const externalProcesses = yield* Ref.make< - ReadonlyArray<{ readonly pid: number; readonly startTimeMs?: number }> - >([]); - const retryCount = yield* Ref.make(0); - const nativeHealth = yield* Ref.make({ - status: "healthy", - hello: Option.some({ - version: 3, - type: "hello", - sidecarVersion: "0.1.0", - sidecarPid: 9_000, - platform: "test", - arch: "test", - capabilities: { - cumulativeCpuTime: true, - currentCpuPercent: true, - residentMemory: true, - virtualMemory: true, - ioBytes: true, - processStartTime: true, - processTree: true, - }, - }), - lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), - lastError: Option.none(), - restartCount: 2, - sampleIntervalMs: 1_000, - }); - const nativeHealthChanges = - yield* PubSub.sliding(4); - const layerNative = NativeTelemetryClient.layerTest({ - setExternalProcesses: (processes) => Ref.set(externalProcesses, processes), - readHistory: () => Effect.succeed(samples.slice(0, 2)), - sampleNow: Ref.modify(sampleIndex, (index) => { - const sampleIndex = Math.min(index, samples.length - 1); - return [nativeGeneration(samples[sampleIndex]!, sampleIndex === 2 ? 3 : 2), index + 1]; - }), - retry: Ref.updateAndGet(retryCount, (count) => count + 1).pipe(Effect.as(true)), - health: Ref.get(nativeHealth), - subscribeHealth: Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(nativeHealthChanges); - const latest = yield* Ref.get(nativeHealth); - return { - latest, - changes: Stream.fromSubscription(subscription), - }; - }), - }); - const layerDesktop = DesktopTelemetryReceiver.layerTest({ - latest: Effect.succeedSome(desktopSnapshot(startedAt)), - health: Effect.succeed({ - status: "healthy", - lastSampleAt: Option.some(DateTime.makeUnsafe(startedAt)), + yield* TestClock.adjust(Duration.minutes(1)); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const externalProcesses = [{ pid: 5_000, startTimeMs: 300 }]; + const historyReads = yield* Ref.make(0); + const status = + yield* Ref.make("healthy"); + const nativeLayer = NativeTelemetryClient.layerTest({ + readHistory: () => + Ref.update(historyReads, (count) => count + 1).pipe( + Effect.as([ + nativeSnapshot({ + sequence: 1, + sampledAtUnixMs: now - 2_000, + childCpuTimeMs: 0, + childWriteBytes: 0, + externalProcesses, + }), + nativeSnapshot({ + sequence: 2, + sampledAtUnixMs: now - 1_000, + childCpuTimeMs: 0, + childWriteBytes: 0, + externalProcesses, + }), + ]), + ), + health: Effect.map(Ref.get(status), (current) => ({ + status: current, + hello: Option.none(), + lastSampleAt: Option.none(), lastError: Option.none(), - }), + restartCount: 0, + sampleIntervalMs: 5_000, + })), }); - const layerAttribution = ResourceAttribution.layer; - const layerDependencies = Layer.mergeAll(layerNative, layerDesktop, layerAttribution); - const layerTelemetry = ResourceTelemetry.layer.pipe(Layer.provide(layerDependencies)); - const layer = Layer.mergeAll(layerDependencies, layerTelemetry); + const layer = ResourceTelemetry.layer.pipe( + Layer.provide( + Layer.mergeAll( + nativeLayer, + DesktopTelemetryReceiver.layerTest(), + ResourceAttribution.layer, + ), + ), + ); yield* Effect.gen(function* () { const telemetry = yield* ResourceTelemetry.ResourceTelemetry; - const attribution = yield* ResourceAttribution.ResourceAttribution; - - expect(yield* Ref.get(externalProcesses)).toEqual([{ pid: 5_000, startTimeMs: 300 }]); - - yield* attribution.record({ - component: "provider-event-log", - operation: "append", - logicalWriteBytes: 512, - count: 2, - durationMs: 4, - }); - const first = yield* telemetry.refresh; - expect(first.groups.backend.processCount).toBe(2); - expect(first.groups.electron.processCount).toBe(1); - expect(first.groups.monitor.processCount).toBe(1); - expect(first.power.onBattery).toBe("true"); - expect(Option.getOrNull(first.speedLimitPercent)).toBe(90); - expect(first.attribution.entries).toEqual([ - { - component: "provider-event-log", - operation: "append", - logicalReadBytes: 0, - logicalWriteBytes: 512, - count: 2, - durationMs: 4, - }, - ]); - - yield* TestClock.adjust(Duration.seconds(1)); - const second = yield* telemetry.refresh; - const codex = second.processes.find((entry) => entry.identity.pid === 4_242); - expect(codex?.cpuPercent).toBe(25); - expect(codex?.ioWriteBytesPerSecond).toBe(4_000); - expect(second.groups.backend.ioWriteBytes).toBe(4_000); - expect(second.health.collectionDurationMicros).toBe(300); - expect(second.health.scannedProcessCount).toBe(80); - expect(second.health.inaccessibleProcessCount).toBe(1); - - const history = yield* telemetry.readHistory({ - windowMs: 60_000, - bucketMs: 10_000, - }); - expect(history.retainedSampleCount).toBeGreaterThan(0); - expect( - history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.sampleCount, - ).toBe(2); - expect(history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.cpuTimeMs).toBe( - 250, - ); - expect( - history.topProcesses.find((entry) => entry.identity.pid === 4_242)?.ioWriteBytes, - ).toBe(4_000); - expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( - 4_000, - ); - - const retry = yield* telemetry.retry; - expect(retry.accepted).toBe(true); - expect(yield* Ref.get(retryCount)).toBe(1); - - yield* Ref.update(nativeHealth, (current) => ({ - ...current, - hello: Option.map(current.hello, (hello) => ({ - ...hello, - sidecarPid: 9_001, - })), - restartCount: 3, - })); - yield* TestClock.adjust(Duration.seconds(1)); - const restarted = yield* telemetry.refresh; - expect(DateTime.toEpochMillis(restarted.readAt)).toBe(startedAt + 2_000); - expect(Option.getOrNull(restarted.health.sidecarPid)).toBe(9_001); + const [first, second] = yield* Effect.all([telemetry.readUsage, telemetry.readUsage]); + expect(first).toEqual(second); + expect(first?.processCount).toBe(4); + expect(first?.rssBytes).toBe(4 * 1_024); + expect(yield* Ref.get(historyReads)).toBe(1); - yield* Ref.update(nativeHealth, (current) => ({ - ...current, - status: "degraded" as const, - lastError: Option.some("collector exited"), - })); - yield* PubSub.publish(nativeHealthChanges, yield* Ref.get(nativeHealth)); - yield* Effect.yieldNow; - const healthUpdate = yield* telemetry.latest; - expect(healthUpdate.health.native.status).toBe("degraded"); - expect(Option.getOrNull(healthUpdate.health.native.lastError)).toBe("collector exited"); - const degradedHistory = yield* telemetry.readHistory({ - windowMs: 60_000, - bucketMs: 10_000, - }); - expect(degradedHistory.health.native.status).toBe("degraded"); + yield* Ref.set(status, "unavailable"); + yield* TestClock.adjust(Duration.seconds(6)); + expect(yield* telemetry.readUsage).toBeNull(); + expect(yield* Ref.get(historyReads)).toBe(1); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts index fff4588c8468..236c0893bd7a 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts @@ -8,10 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { - buildResourceTelemetryHistory, - normalizeResourceTelemetryHistoryInput, -} from "./ResourceTelemetryHistory.ts"; +import { buildResourceTelemetryHistory } from "./ResourceTelemetryHistory.ts"; const SERVER_PID = 100; const ELECTRON_PID = 200; @@ -132,13 +129,6 @@ function desktopSnapshot(): DesktopHostTelemetrySnapshot { } describe("buildResourceTelemetryHistory", () => { - it("normalizes query bounds before requesting native history", () => { - expect(normalizeResourceTelemetryHistoryInput({ windowMs: 0, bucketMs: 0 })).toEqual({ - windowMs: 1_000, - bucketMs: 1_000, - }); - }); - it("replays native snapshots on demand without applying current Electron metrics", () => { const history = buildResourceTelemetryHistory({ readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), @@ -230,24 +220,6 @@ describe("buildResourceTelemetryHistory", () => { expect(child?.ioWriteBytes).toBe(4_000); }); - it("uses an exact current Electron identity for slightly older native samples", () => { - const history = buildResourceTelemetryHistory({ - readAt: DateTime.makeUnsafe(STARTED_AT_MS + 2_000), - windowMs: 10_000, - bucketMs: 10_000, - sampleIntervalMs: 1_000, - serverPid: SERVER_PID, - sidecarPid: Option.none(), - desktopSnapshot: Option.some(desktopSnapshot()), - snapshots: [snapshot(1, STARTED_AT_MS, 100, 1_000)], - health, - }); - - expect( - history.topProcesses.find((process) => process.identity.pid === ELECTRON_PID)?.category, - ).toBe("electron-main"); - }); - it("uses the preceding sample as a baseline without attributing pre-window deltas", () => { const readAtMs = STARTED_AT_MS + 10_000; const history = buildResourceTelemetryHistory({ diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 74602efb62a5..a9fb05ab1439 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as SqlClient from "effect/sql/SqlClient"; import { ProjectId } from "@t3tools/contracts"; @@ -121,39 +120,6 @@ describe("ReviewService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("attributes file-content workspace violations to the file-content operation", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const detectCalls: Array<{ readonly cwd: string }> = []; - - const error = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review - .getDiffFileContents({ - cwd: outsideRoot, - sourceKind: "working-tree", - changeType: "change", - baseRef: "HEAD", - headRef: null, - oldPath: "file.ts", - newPath: "file.ts", - }) - .pipe(Effect.flip); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); - assert.strictEqual(error.operation, "ReviewService.getDiffFileContents"); - assert.match( - "detail" in error ? error.detail : "", - /must stay within the configured workspace root/, - ); - assert.deepStrictEqual(detectCalls, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("allows previous custom worktree locations but never a filesystem root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -299,45 +265,4 @@ describe("ReviewService", () => { assert.strictEqual(result.cwd, checkout); }).pipe(Effect.provide(NodeServices.layer)), ); - - it.effect("allows diff preview cwd inside the configured workspace root", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const detectCalls: Array<{ readonly cwd: string }> = []; - - const result = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review.getDiffPreview({ cwd: workspaceRoot }); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(result.cwd, workspaceRoot); - assert.deepStrictEqual(result.sources, []); - assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves unexpected path-resolution failures", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); - const invalidCwd = `${workspaceRoot}\0invalid`; - const detectCalls: Array<{ readonly cwd: string }> = []; - - const error = yield* Effect.gen(function* () { - const review = yield* ReviewService.ReviewService; - return yield* review.getDiffPreview({ cwd: invalidCwd }).pipe(Effect.flip); - }).pipe(Effect.provide(layer({ workspaceRoot, baseDir, detectCalls }))); - - assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); - if (error._tag !== "VcsRepositoryDetectionError") return; - assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); - assert.strictEqual(error.cwd, invalidCwd); - assert.match(error.detail, /Failed to resolve a path/); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.deepStrictEqual(detectCalls, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); }); diff --git a/apps/server/src/rpcInitialItems.memory.test.ts b/apps/server/src/rpcInitialItems.memory.test.ts deleted file mode 100644 index 981269d52c70..000000000000 --- a/apps/server/src/rpcInitialItems.memory.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - memory assertions run in an isolated Node process with explicit GC. -import * as NodeChildProcess from "node:child_process"; -import * as NodeURL from "node:url"; -import * as NodeUtil from "node:util"; -import { expect, it } from "vite-plus/test"; - -const execFile = NodeUtil.promisify(NodeChildProcess.execFile); - -it.each(["prefix", "snapshot", "replay"])( - "releases delivered %s history while RPC subscriptions remain open", - async (mode) => { - const root = NodeURL.fileURLToPath(new URL("../../../", import.meta.url)); - const fixture = NodeURL.fileURLToPath( - new URL("./testkit/RpcInitialMemory.fixture.mjs", import.meta.url), - ); - const { stdout } = await execFile(process.execPath, ["--expose-gc", fixture, root, mode]); - const result = JSON.parse(stdout) as { - checkpoints: ReadonlyArray<{ subscriptions: number; retained: number }>; - }; - expect( - result.checkpoints.map(({ subscriptions, retained }) => ({ subscriptions, retained })), - ).toEqual([ - { subscriptions: 2, retained: 0 }, - { subscriptions: 4, retained: 0 }, - ]); - }, -); diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index 4f4f484244fa..a83de2d78f61 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; -import { - isMissedFixedTimeRun, - isSameSchedule, - missedRunCatchUpAt, - nextScheduledRunAt, - parseTimeOfDay, -} from "./Schedule.ts"; +import { isSameSchedule, missedRunCatchUpAt, nextScheduledRunAt } from "./Schedule.ts"; const LONDON = "Europe/London"; @@ -91,28 +85,6 @@ describe("missed fixed-time run catch-up", () => { }); describe("scheduled task schedule calculation", () => { - it("parses 24-hour times", () => { - expect(parseTimeOfDay("09:30")).toEqual({ hour: 9, minute: 30 }); - expect(parseTimeOfDay("23:59")).toEqual({ hour: 23, minute: 59 }); - expect(parseTimeOfDay("25:00")).toBeNull(); - }); - - it("calculates interval schedules from the supplied instant", () => { - const next = nextScheduledRunAt( - { type: "interval", everyMs: 5 * 60_000 }, - DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"), - ); - expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:05:00.000Z"); - }); - - it("clamps legacy sub-minute intervals to the one-minute execution floor", () => { - const next = nextScheduledRunAt( - { type: "interval", everyMs: 1_000 }, - DateTime.makeUnsafe("2026-07-01T16:00:00.000Z"), - ); - expect(next ? DateTime.formatIso(DateTime.toUtc(next)) : null).toBe("2026-07-01T16:01:00.000Z"); - }); - it("skips to the next matching fixed-time weekday", () => { const next = nextScheduledRunAt( { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5] }, @@ -135,18 +107,6 @@ describe("scheduled task schedule calculation", () => { expect(parts?.minute).toBe(0); }); - it("skips fixed-time runs missed by more than the grace window", () => { - const fixedTime = { type: "fixed_time", timeOfDay: "09:00" } as const; - const dueAt = DateTime.makeUnsafe("2026-07-01T09:00:00.000Z"); - const withinGrace = DateTime.makeUnsafe("2026-07-01T09:05:00.000Z"); - const pastGrace = DateTime.makeUnsafe("2026-07-01T15:00:00.000Z"); - expect(isMissedFixedTimeRun(fixedTime, dueAt, withinGrace)).toBe(false); - expect(isMissedFixedTimeRun(fixedTime, dueAt, pastGrace)).toBe(true); - expect(isMissedFixedTimeRun({ type: "interval", everyMs: 60_000 }, dueAt, pastGrace)).toBe( - false, - ); - }); - it("compares schedules structurally", () => { expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), @@ -197,17 +157,4 @@ describe("scheduled task schedule calculation", () => { ), ).toBe(false); }); - - it.each([ - ["9:00", "09:00"], - ["09:00", "9:00"], - ["0:30", "00:30"], - ])("treats %s and %s as the same fixed-time schedule", (before, after) => { - expect( - isSameSchedule( - { type: "fixed_time", timeOfDay: before }, - { type: "fixed_time", timeOfDay: after }, - ), - ).toBe(true); - }); }); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index 386de4c49b62..bb4f9b67c3ce 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -3,7 +3,7 @@ import * as DateTime from "effect/DateTime"; const MINUTE_MS = 60_000; -export function parseTimeOfDay(value: string): { hour: number; minute: number } | null { +function parseTimeOfDay(value: string): { hour: number; minute: number } | null { const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); if (!match) return null; return { hour: Number(match[1]), minute: Number(match[2]) }; diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts deleted file mode 100644 index 4a1a58505c5c..000000000000 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { expect, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import { afterEach, vi } from "vite-plus/test"; - -import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; -import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; -import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; -import * as Scheduler from "../scheduling/Scheduler.ts"; -import * as SecretRequests from "../secrets/SecretRequests.ts"; -import { ServerActivation } from "../serverActivation.ts"; -import * as ScheduledTaskService from "./ScheduledTaskService.ts"; - -afterEach(() => vi.unstubAllEnvs()); - -const dependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({ - launch: () => Effect.die("synthetic dispatch failure"), - }), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), -); -const serviceLayer = ScheduledTaskService.layer.pipe(Layer.provide(dependencies)); -const decodeInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); -const input = { - commandId: "interval-duration", - title: "Interval review", - prompt: "Review the open pull requests.", - enabled: true, - projectId: "project-interval-duration", - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", -}; - -it.effect.each([ - { - zone: "America/New_York", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T05:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-11-01T07:30:00.000Z", - }, - { - zone: "America/New_York", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "UTC", - from: "2026-11-01T06:30:00.000Z", - everyMs: 60_000, - expected: "2026-11-01T06:31:00.000Z", - }, - { - zone: "Asia/Tokyo", - from: "2026-03-08T06:30:00.000Z", - everyMs: 7_200_000, - expected: "2026-03-08T08:30:00.000Z", - }, -] as const)("persists elapsed interval $zone from $from", ({ zone, from, everyMs, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", zone); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ ...input, schedule: { type: "interval", everyMs } }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - - const paused = yield* service.setEnabled({ id: created.task.id, enabled: false }); - expect(paused.task.nextRunAt).toBeNull(); - const resumed = yield* service.setEnabled({ id: created.task.id, enabled: true }); - expect(resumed.task.nextRunAt).toBe(expected); - - // Exercise completion rescheduling without starting any provider process. - const completed = yield* service.runNow({ id: created.task.id }); - expect(completed.task.lastRunStatus).toBe("failed"); - expect(completed.threadId).toBeNull(); - expect(completed.task.runCount).toBe(1); - expect(completed.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - // Keep the real scheduler parked while exercising finite service actions. - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); - -it.effect.each([ - { from: "2026-03-07T23:30:00.000Z", expected: "2026-03-08T13:00:00.000Z" }, - { from: "2026-10-31T23:30:00.000Z", expected: "2026-11-01T14:00:00.000Z" }, -] as const)("keeps fixed-time schedules on the local calendar from $from", ({ from, expected }) => - Effect.gen(function* () { - vi.stubEnv("TZ", "America/New_York"); - yield* TestClock.setTime(Date.parse(from)); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const decoded = yield* decodeInput({ - ...input, - schedule: { type: "fixed_time", timeOfDay: "09:00" }, - }); - const created = yield* service.upsert(decoded); - expect(created.task.nextRunAt).toBe(expected); - expect((yield* service.list()).tasks[0]?.nextRunAt).toBe(expected); - }).pipe(Effect.provide(serviceLayer)); - }).pipe( - Effect.provideService(ServerActivation, Effect.never), - Effect.provide(SqlitePersistence.layerMemory), - ), -); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 91d58d53b684..bcb110a5da71 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -6,7 +6,6 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -59,52 +58,6 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); -it.effect("includes the scheduler zone in empty lists and subscription updates", () => - Effect.gen(function* () { - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - Layer.mock(ThreadManagementService.ThreadManagementService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - ); - yield* Effect.gen(function* () { - const service = yield* ScheduledTaskService.ScheduledTaskService; - const timeZone = DateTime.zoneToString(DateTime.zoneMakeLocal()); - expect(yield* service.list()).toEqual({ tasks: [], timeZone }); - - const input = yield* decodeUpsertInput({ - title: "Morning review", - prompt: "Review the open pull requests.", - enabled: false, - schedule: { type: "fixed_time", timeOfDay: "09:00" }, - projectId: "project-schedule-zone", - workspaceStrategy: { type: "root" }, - modelSelection: { instanceId: "codex", model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - }); - const snapshots = yield* service.subscribeList().pipe( - Stream.tap((snapshot) => - snapshot.tasks.length === 0 ? service.upsert(input) : Effect.void, - ), - Stream.take(2), - Stream.runCollect, - ); - const populated = yield* service.list(); - expect(populated).toMatchObject({ - timeZone, - tasks: [{ title: "Morning review", schedule: input.schedule }], - }); - expect(snapshots).toEqual([{ tasks: [], timeZone }, populated]); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)), -); - it.effect("preserves a due run when a save only pads the scheduled hour", () => Effect.gen(function* () { const dueAt = DateTime.makeZonedUnsafe( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index c38d4999c55f..bcd876519194 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -15,6 +15,7 @@ import { type ScheduledTaskListWebhookDeliveriesResult, type ScheduledTaskRotateWebhookTokenInput, type ScheduledTaskWebhookDeliveryOutcome, + type ScheduledTaskWebhookFilter, type ScheduledTaskListResult, type ScheduledTaskMutationResult, type ScheduledTaskRunNowInput, @@ -28,6 +29,7 @@ import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Duration from "effect/Duration"; import * as Metric from "effect/Metric"; import * as Layer from "effect/Layer"; @@ -58,15 +60,38 @@ import { renderWebhookPrompt, type WebhookRequest, } from "./webhookTemplate.ts"; +import { + acceptWebhookDedupe, + acceptImmediateWebhookDedupe, + releaseWebhookReplay, + decodePendingTask, + decodePendingRequest, + dedupeError, +} from "./webhookDedupe.ts"; +import { describeFailedWebhookFilter, findFailedWebhookFilter } from "./webhookFilter.ts"; import { constantTimeEquals, verifyWebhookSignature } from "./webhookVerification.ts"; export const WEBHOOK_ROUTE_PREFIX = "/api/hooks"; +const isScheduledTaskError = Schema.is(ScheduledTaskError); +const PROMPT_TOO_LONG = "The filled-in prompt is too long."; const WEBHOOK_DELIVERY_RETENTION = 50; +const WEBHOOK_DEDUPE_DELIVERY_RETENTION = 20; +const WEBHOOK_FILTERED_DELIVERY_RETENTION = 20; +const WEBHOOK_DEDUPE_LOG_PER_MINUTE = 10; +const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; /** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; +const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ + "accepted", + "dispatch_failed", + "rejected_signature", + "disabled", + "rate_limited", + "expired", +]); interface WebhookOrigin { readonly relayHookBaseUrl: string | null; @@ -98,7 +123,7 @@ class WebhookDeliverySkipped extends Data.TaggedError("WebhookDeliverySkipped")< }> {} interface RateWindow { - readonly accepted: ReadonlyArray; + readonly accepted: ReadonlyArray<{ readonly at: number }>; readonly rejectedLogged: boolean; } @@ -113,12 +138,14 @@ export interface WebhookTriggerRequest extends WebhookRequest { export type WebhookDeliveryOutcome = | "accepted" | "duplicate" + | "coalesced" | "prompt_too_long" | "queue_full" | "rate_limited" | "disabled" | "rejected_signature" | "expired" + | "filtered" | "not_found" | "error"; @@ -126,7 +153,7 @@ export type WebhookTriggerResult = | { readonly _tag: "accepted"; readonly deliveryId: ScheduledTaskWebhookDeliveryId; - readonly outcome: "accepted" | "duplicate" | "prompt_too_long"; + readonly outcome: "accepted" | "duplicate" | "coalesced" | "prompt_too_long" | "filtered"; } | { readonly _tag: "not_found" } | { readonly _tag: "rejected_signature" } @@ -137,6 +164,9 @@ export type WebhookTriggerResult = } | { readonly _tag: "expired" }; +const webhookFilters = (filters: ReadonlyArray | undefined) => + filters?.length ? { filters } : {}; + const decodeTask = Schema.decodeUnknownEffect(ScheduledTask); const decodeTaskId = Schema.decodeUnknownOption(ScheduledTaskId); const decodeScheduleJson = Schema.decodeUnknownEffect( @@ -194,6 +224,7 @@ interface WebhookDeliveryRow { readonly signature_verified: number; readonly missing_fields_json: string; readonly rendered_prompt: string | null; + readonly pending_window: number; readonly error: string | null; } @@ -388,6 +419,16 @@ export const layer = Layer.effect( // finish late release their own count, never a recreated task's. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); + const webhookRejectedRateWindows = yield* Ref.make>( + new Map(), + ); + const webhookDedupeLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); + const pendingInFlight = yield* Ref.make>(new Set()); + const webhookFilteredLogWindows = yield* Ref.make< + ReadonlyMap> + >(new Map()); const activeRuns = yield* Ref.make>(new Set()); // Sliding(1) coalesces the dirty-signal: every notification triggers a // full list() re-emit anyway, so a slow subscriber only ever needs the @@ -607,11 +648,12 @@ export const layer = Layer.effect( ), ); - const markRunning = (id: ScheduledTaskId, startedAtIso: string) => + const markRunning = (id: ScheduledTaskId, startedAtIso: string, webhookDeliveryId?: string) => sql<{ task_id: string }>` UPDATE scheduled_tasks SET updated_at = ${startedAtIso}, last_run_at = ${startedAtIso}, + last_run_webhook_delivery_id = ${webhookDeliveryId ?? null}, last_run_status = 'running', last_run_error = NULL WHERE task_id = ${id} @@ -653,9 +695,10 @@ export const layer = Layer.effect( // markCompleted, write a full terminal record so runDueTasks neither skips // the task forever (it filters out 'running' rows) nor re-fires it // immediately: the dispatch may already have gone out, so next_run_at must - // advance and run_count must count the attempt. + // advance. Pending webhook attempts count only with their completion state; + // other attempts count here. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string) { + function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -666,12 +709,15 @@ export const layer = Layer.effect( last_run_error = ${message}, next_run_at = ${nextRunAt(source, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE delivery_id = ${pendingDeliveryId ?? null} AND state = 'running' + ) THEN 0 ELSE 1 END WHERE task_id = ${task.id} AND last_run_status = 'running' `; yield* notifyChanged; }, - (effect, task, _message) => + (effect, task, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -710,10 +756,12 @@ export const layer = Layer.effect( const runTask = Effect.fn("ScheduledTaskService.runTask")(function* ( task: ScheduledTask, - // catch_up is a timer run firing late for an occurrence the server slept - // through; it is distinct only in the dispatched command id and logs. trigger: "scheduled" | "catch_up" | "manual" | "webhook", - webhook?: { readonly deliveryId: string; readonly prompt: string }, + webhook?: { + readonly deliveryId: string; + readonly prompt: string; + readonly dispatched: Ref.Ref; + }, ) { const timerRun = trigger === "scheduled" || trigger === "catch_up"; const reserved = yield* Ref.modify(activeRuns, (active) => { @@ -748,6 +796,12 @@ export const layer = Layer.effect( } return { task, threadId: null }; } + if ( + webhook !== undefined && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId} AND state = 'completed'`) + .length > 0 + ) + return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -773,7 +827,22 @@ export const layer = Layer.effect( if (reason !== null) return yield* new WebhookDeliverySkipped({ reason }); } - yield* markRunning(active.id, startedAtIso); + if (webhook === undefined) yield* markRunning(active.id, startedAtIso); + else + yield* sql + .withTransaction( + Effect.gen(function* () { + yield* markRunning(active.id, startedAtIso, webhook.deliveryId); + // Recovery must distinguish this delivery from other windows waiting on the same task. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'running' WHERE delivery_id = ${webhook.deliveryId}`; + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not start webhook run.", { taskId: task.id, cause }), + ), + ); + yield* notifyChanged; const fireKey = @@ -833,8 +902,9 @@ export const layer = Layer.effect( ), ); - const completedAt = yield* localNow; const runSucceeded = result._tag === "Success"; + if (webhook !== undefined && runSucceeded) yield* Ref.set(webhook.dispatched, true); + const completedAt = yield* localNow; const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; @@ -853,7 +923,7 @@ export const layer = Layer.effect( // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. - yield* markCompleted({ + const completion = markCompleted({ id: task.id, completedAtIso: completed.updatedAt, nextRunAtIso: completed.nextRunAt, @@ -861,11 +931,32 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); + if (webhook === undefined) yield* completion; + else + yield* sql + .withTransaction( + Effect.gen(function* () { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) yield* completion; + } + }), + ) + .pipe( + Effect.mapError((cause) => + taskError("Could not record webhook completion.", { taskId: task.id, cause }), + ), + ); yield* notifyChanged; } return { task: completed, threadId }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause))), + Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -956,7 +1047,11 @@ export const layer = Layer.effect( last_run_error = 'Run was interrupted by a server restart.', next_run_at = ${nextRunAt(decoded.success, now)}, updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; return; @@ -970,7 +1065,11 @@ export const layer = Layer.effect( SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', updated_at = ${iso(now)}, - run_count = run_count + 1 + run_count = run_count + CASE WHEN EXISTS ( + SELECT 1 FROM scheduled_task_webhook_pending + WHERE task_id IS ${row.task_id} AND state = 'running' + AND delivery_id = scheduled_tasks.last_run_webhook_delivery_id + ) THEN 0 ELSE 1 END WHERE task_id IS ${row.task_id} AND last_run_status = 'running' `; }), @@ -987,10 +1086,7 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ - tasks, - timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), - })), + Effect.map((tasks) => ({ tasks })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); @@ -1046,6 +1142,20 @@ export const layer = Layer.effect( prefix: input.schedule.signature.prefix, }, maxDeliveryAgeMinutes: input.schedule.maxDeliveryAgeMinutes ?? null, + ...(input.schedule.dedupe === undefined + ? existingTask?.schedule.type === "webhook" && + existingTask.schedule.dedupe !== undefined + ? { dedupe: existingTask.schedule.dedupe } + : {} + : { dedupe: input.schedule.dedupe }), + ...webhookFilters( + // Omitted keeps the stored filters, so a client that predates + // them can re-save a task without clearing them. + input.schedule.filters ?? + (existingTask?.schedule.type === "webhook" + ? existingTask.schedule.filters + : undefined), + ), } : input.schedule; const webhook = @@ -1165,6 +1275,21 @@ export const layer = Layer.effect( next.delete(id); return next; }), + Ref.update(webhookRejectedRateWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookDedupeLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), + Ref.update(webhookFilteredLogWindows, (windows) => { + const next = new Map(windows); + next.delete(id); + return next; + }), Ref.update(webhookPermits, (permits) => { const next = new Map(permits); next.delete(id); @@ -1276,6 +1401,7 @@ export const layer = Layer.effect( bodyBytes: row.body_bytes, outcome: row.outcome as ScheduledTaskWebhookDeliveryOutcome, signatureVerified: row.signature_verified === 1, + pendingWindow: row.outcome === "accepted" && row.pending_window === 1, missingFields: Option.getOrElse(decodeMissingFieldsJson(row.missing_fields_json), () => []), error: row.error, }); @@ -1284,11 +1410,18 @@ export const layer = Layer.effect( input, ) => sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} ORDER BY received_at DESC, rowid DESC `.pipe( - Effect.map((rows) => ({ deliveries: rows.map(decodeDeliverySummary) })), + Effect.map((rows) => ({ + deliveries: rows + .filter( + (row) => input.acceptAnyOutcome === true || LEGACY_DELIVERY_OUTCOMES.has(row.outcome), + ) + .map(decodeDeliverySummary), + })), Effect.mapError((cause) => taskError("Could not list webhook deliveries.", { taskId: input.id, cause }), ), @@ -1298,15 +1431,19 @@ export const layer = Layer.effect( "ScheduledTaskService.getWebhookDelivery", )(function* (input) { const rows = yield* sql` - SELECT * FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} + SELECT d.*, EXISTS (SELECT 1 FROM scheduled_task_webhook_pending p + WHERE p.delivery_id = d.delivery_id AND p.state IN ('pending', 'dispatching')) AS pending_window + FROM scheduled_task_webhook_deliveries d WHERE task_id = ${input.id} AND delivery_id = ${input.deliveryId} `.pipe( Effect.mapError((cause) => taskError("Could not load webhook delivery.", { taskId: input.id, cause }), ), ); const row = rows[0]; - if (row === undefined) { + if ( + row === undefined || + (input.acceptAnyOutcome !== true && !LEGACY_DELIVERY_OUTCOMES.has(row.outcome)) + ) { return yield* taskError("Webhook delivery not found.", { taskId: input.id }); } return { @@ -1321,6 +1458,24 @@ export const layer = Layer.effect( }; }); + const deliveryRequestBody = (request: WebhookTriggerRequest) => { + const truncated = request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; + return { + truncated, + body: truncated + ? new TextDecoder().decode(request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) + : request.bodyText, + }; + }; + const replaceDeliveryRequest = (deliveryId: string, request: WebhookTriggerRequest) => { + const logged = deliveryRequestBody(request); + return sql`UPDATE scheduled_task_webhook_deliveries SET + method = ${request.method}, query = ${redactQuery(request.query)}, + headers_json = ${encodeHeadersJson(redactHeaders(request.headers))}, body = ${logged.body}, + body_bytes = ${request.body.byteLength}, body_truncated = ${logged.truncated ? 1 : 0} + WHERE delivery_id = ${deliveryId}`.pipe(Effect.asVoid, Effect.mapError(dedupeError)); + }; + const recordDelivery = (input: { readonly id: string; readonly taskId: ScheduledTaskId; @@ -1332,10 +1487,7 @@ export const layer = Layer.effect( readonly renderedPrompt: string | null; readonly error?: string; }) => { - const truncated = input.request.body.byteLength > WEBHOOK_DELIVERY_LOG_BODY_LIMIT; - const loggedBody = truncated - ? new TextDecoder().decode(input.request.body.subarray(0, WEBHOOK_DELIVERY_LOG_BODY_LIMIT)) - : input.request.bodyText; + const { truncated, body: loggedBody } = deliveryRequestBody(input.request); return sql .withTransaction( Effect.gen(function* () { @@ -1366,15 +1518,28 @@ export const layer = Layer.effect( rendered_prompt = excluded.rendered_prompt, error = excluded.error `; + // Each suppression bucket retains its own rows, never real run history. + const bucket = (outcome: string, error: string | undefined) => + outcome === "filtered" + ? "filtered" + : outcome === "duplicate" || + outcome === "coalesced" || + outcome === "rate_limited" || + error === PROMPT_TOO_LONG + ? "dedupe" + : "main"; + const retentionBucket = bucket(input.outcome, input.error); + const rowBucket = sql.literal(`CASE WHEN outcome = 'filtered' THEN 'filtered' + WHEN outcome IN ('duplicate', 'coalesced', 'rate_limited') OR error = 'The filled-in prompt is too long.' THEN 'dedupe' + ELSE 'main' END`); yield* sql` DELETE FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} AND delivery_id NOT IN ( SELECT delivery_id FROM scheduled_task_webhook_deliveries - WHERE task_id = ${input.taskId} - -- rowid breaks timestamp ties in arrival order; delivery ids are random. + WHERE task_id = ${input.taskId} AND ${rowBucket} = ${retentionBucket} ORDER BY received_at DESC, rowid DESC - LIMIT ${WEBHOOK_DELIVERY_RETENTION} + LIMIT ${retentionBucket === "main" ? WEBHOOK_DELIVERY_RETENTION : retentionBucket === "filtered" ? WEBHOOK_FILTERED_DELIVERY_RETENTION : WEBHOOK_DEDUPE_DELIVERY_RETENTION} ) `; }), @@ -1393,13 +1558,15 @@ export const layer = Layer.effect( WHERE delivery_id = ${deliveryId} `.pipe(Effect.ignore); - /** - * Sliding one-minute window counting every request with a valid token, - * including ones the signature check later rejects. - */ - const takeRateSlot = (id: ScheduledTaskId, nowMs: number) => + /** Sliding one-minute window for deliveries that can start work. */ + const takeRateSlot = ( + id: ScheduledTaskId, + nowMs: number, + rateWindows = webhookRateWindows, + reservation = { at: nowMs }, + ) => Ref.modify( - webhookRateWindows, + rateWindows, ( windows, ): readonly [ @@ -1407,7 +1574,7 @@ export const layer = Layer.effect( ReadonlyMap, ] => { const current = windows.get(id); - const recent = (current?.accepted ?? []).filter((at) => nowMs - at < 60_000); + const recent = (current?.accepted ?? []).filter((entry) => nowMs - entry.at < 60_000); if (recent.length >= WEBHOOK_RATE_LIMIT_PER_MINUTE) { const first = current?.rejectedLogged !== true; return [ @@ -1417,11 +1584,35 @@ export const layer = Layer.effect( } return [ "allowed", - new Map(windows).set(id, { accepted: [...recent, nowMs], rejectedLogged: false }), + new Map(windows).set(id, { accepted: [...recent, reservation], rejectedLogged: false }), ]; }, ); + const takeDedupeLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify(webhookDedupeLogWindows, (windows) => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_DEDUPE_LOG_PER_MINUTE + ? ([false, windows] as const) + : ([true, new Map(windows).set(id, [...recent, nowMs])] as const); + }); + const releaseReplay = (deliveryId: string) => + releaseWebhookReplay(deliveryId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.mapError(dedupeError), + ); + /** Sliding one-minute window of filtered requests written to the delivery log. */ + const takeFilteredLogSlot = (id: ScheduledTaskId, nowMs: number) => + Ref.modify( + webhookFilteredLogWindows, + (windows): readonly [boolean, ReadonlyMap>] => { + const recent = (windows.get(id) ?? []).filter((at) => nowMs - at < 60_000); + return recent.length >= WEBHOOK_FILTERED_LOG_PER_MINUTE + ? [false, windows] + : [true, new Map(windows).set(id, [...recent, nowMs])]; + }, + ); + const webhookPermit = Effect.fn("ScheduledTaskService.webhookPermit")(function* ( id: ScheduledTaskId, ) { @@ -1438,6 +1629,83 @@ export const layer = Layer.effect( const serviceScope = yield* Effect.scope; + const releaseFailedDelivery = Effect.fn("ScheduledTaskService.releaseFailedDelivery")( + function* (deliveryId: string) { + yield* releaseReplay(deliveryId); + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id IN ( + SELECT substr(value, ${"delivery:relay:".length + 1}) FROM scheduled_task_webhook_pending, json_each(delivery_ids_json) + WHERE delivery_id = ${deliveryId} AND value LIKE 'delivery:relay:%' + )`.pipe(Effect.mapError(dedupeError)); + if (deliveryId.startsWith("delivery:relay:")) + yield* sql`DELETE FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = ${deliveryId.slice("delivery:relay:".length)}`.pipe( + Effect.mapError(dedupeError), + ); + }, + (effect) => sql.withTransaction(effect).pipe(Effect.mapError(dedupeError)), + ); + + const runWebhookDelivery = Effect.fn("ScheduledTaskService.runWebhookDelivery")(function* ( + task: ScheduledTask, + deliveryId: string, + prompt: string, + ) { + const runOutcome = (outcome: "started" | "skipped" | "failed") => + Effect.all([ + Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), + Metrics.increment(Metrics.webhookRunsTotal, { outcome }), + ]); + const dispatched = yield* Ref.make(false); + return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( + Effect.flatMap(({ task: completed }) => + completed.lastRunStatus === "failed" + ? runOutcome("failed").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ) + : runOutcome("started"), + ), + Effect.catchTags({ + WebhookDeliverySkipped: (skipped) => + runOutcome("skipped").pipe( + Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), + Effect.andThen(releaseFailedDelivery(deliveryId)), + ), + }), + Effect.as(true), + // Dispatch success owns the replay key even if later bookkeeping fails. + Effect.catchCause((cause) => + Effect.gen(function* () { + const started = yield* Ref.get(dispatched); + yield* Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }); + yield* runOutcome(started ? "started" : "failed"); + yield* markDeliveryFailed( + deliveryId, + started + ? "The run started, but completion could not be recorded." + : "The run failed to start.", + ); + if (!started) { + yield* sql + .withTransaction( + Effect.gen(function* () { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'failed' + WHERE delivery_id = ${deliveryId} AND state = 'running' RETURNING delivery_id`; + if (counted.length > 0) + yield* sql`UPDATE scheduled_tasks SET run_count = run_count + 1 + WHERE task_id = ${task.id} AND created_at = ${task.createdAt}`; + }), + ) + .pipe(Effect.mapError(dedupeError)); + yield* releaseFailedDelivery(deliveryId); + } + // Pending windows retry the same command id to finish bookkeeping. + return !started; + }), + ), + ); + }); + // Delivery attributes belong to the enclosing triggerWebhook span. const observeDelivery = Effect.fnUntraced(function* ( request: WebhookTriggerRequest, @@ -1585,16 +1853,19 @@ export const layer = Layer.effect( ...(details.error === undefined ? {} : { error: details.error }), }); - // Only the first rejected request in a window is logged, so a flood - // cannot write rows or push the real deliveries out of the log. - const slot = yield* takeRateSlot(task.id, DateTime.toEpochMillis(now)); - if (slot !== "allowed") { - if (slot === "first_rejected") yield* log("rate_limited"); + // Reject floods before signature work or rejection-log writes; valid + // deliveries give this reservation back once validation succeeds. + const rejectionReservation = { at: DateTime.toEpochMillis(now) }; + const rejectionSlot = yield* takeRateSlot( + task.id, + DateTime.toEpochMillis(now), + webhookRejectedRateWindows, + rejectionReservation, + ); + if (rejectionSlot !== "allowed") { + if (rejectionSlot === "first_rejected") yield* log("rate_limited"); yield* observe("rate_limited"); - return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "rate_limited" as const, - }); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); } if (!task.enabled) { yield* log("disabled"); @@ -1628,6 +1899,122 @@ export const layer = Layer.effect( return { _tag: "expired" as const }; } + const refundRejectionSlot = Ref.update(webhookRejectedRateWindows, (windows) => { + const current = windows.get(task.id); + if (current === undefined) return windows; + return new Map(windows).set(task.id, { + ...current, + accepted: current.accepted.filter((entry) => entry !== rejectionReservation), + }); + }); + + const failedFilter = findFailedWebhookFilter(schedule.filters ?? [], request); + if (failedFilter !== null) { + if (yield* takeFilteredLogSlot(task.id, DateTime.toEpochMillis(now))) { + yield* log("filtered", { + signatureVerified: signature !== null, + error: describeFailedWebhookFilter(failedFilter), + }); + } + yield* refundRejectionSlot; + yield* observe("filtered"); + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "filtered" as const, + }); + } + + const runWindow = (yield* Ref.get(webhookRateWindows)).get(task.id); + if ( + schedule.dedupe === undefined && + (runWindow?.accepted.filter((entry) => DateTime.toEpochMillis(now) - entry.at < 60_000) + .length ?? 0) >= WEBHOOK_RATE_LIMIT_PER_MINUTE + ) { + if ((yield* takeRateSlot(task.id, DateTime.toEpochMillis(now))) === "first_rejected") + yield* log("rate_limited"); + yield* observe("rate_limited"); + return yield* releaseClaim({ _tag: "rate_limited", outcome: "rate_limited" } as const); + } + const reserve = takeRateSlot(task.id, DateTime.toEpochMillis(now)).pipe( + Effect.flatMap((slot) => (slot === "allowed" ? Effect.void : Effect.fail(slot))), + ); + const queueKey = `${task.id}\u0000${task.createdAt}`; + const checkCapacity = Effect.gen(function* () { + const [row] = yield* sql<{ + count: number; + }>`SELECT COUNT(*) AS count FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND state IN ('pending', 'dispatching', 'running')`; + if ( + (row?.count ?? 0) + ((yield* Ref.get(webhookQueued)).get(queueKey) ?? 0) >= + WEBHOOK_MAX_QUEUED_PER_TASK + ) + return yield* Effect.fail("queue_full" as const); + }).pipe( + Effect.mapError((cause) => (cause === "queue_full" ? cause : dedupeError(cause))), + ); + const admit = (effect: Effect.Effect) => + sql.withTransaction(effect).pipe( + Effect.catch((cause) => + cause === "first_rejected" || cause === "rejected" || cause === "queue_full" + ? Effect.succeed({ + outcome: + cause === "queue_full" + ? ("queue_full" as const) + : ("rate_limited" as const), + log: cause === "first_rejected", + }) + : Effect.fail(isScheduledTaskError(cause) ? cause : dedupeError(cause)), + ), + ); + const deduped = + schedule.dedupe === undefined + ? "run" + : yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserve, + takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)), + checkCapacity, + (ownerId) => replaceDeliveryRequest(ownerId, request), + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if ( + outcome === "pending" || + outcome === "coalesced" || + (outcome === "duplicate" && + (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now)))) + ) + yield* log(outcome === "pending" ? "accepted" : outcome, { + signatureVerified: signature !== null, + }); + return outcome; + }), + ); + if (typeof deduped !== "string") { + yield* refundRejectionSlot; + if (deduped.log) yield* log("rate_limited"); + yield* observe(deduped.outcome); + return yield* releaseClaim({ _tag: "rate_limited", outcome: deduped.outcome } as const); + } + if (deduped !== "run") { + if (deduped !== "coalesced" && deduped !== "coalesced_unlogged") + yield* refundRejectionSlot; + const outcome = + deduped === "pending" + ? "accepted" + : deduped === "coalesced_unlogged" + ? "coalesced" + : deduped; + yield* observe(outcome); + if (deduped === "duplicate") + return yield* releaseClaim({ _tag: "accepted", deliveryId, outcome } as const); + claimAcquired = false; + return { _tag: "accepted", deliveryId, outcome } as const; + } const frameId = yield* crypto.randomBytes(6).pipe( Effect.map((bytes) => Buffer.from(bytes).toString("hex")), Effect.mapError((cause) => taskError("Could not generate frame id.", { cause })), @@ -1640,85 +2027,192 @@ export const layer = Layer.effect( signatureVerified: signature !== null, missing: rendered.missing, renderedPrompt: rendered.prompt, - error: "The filled-in prompt is too long.", + error: PROMPT_TOO_LONG, }); + yield* releaseReplay(deliveryId); yield* observe("prompt_too_long"); - return { _tag: "accepted" as const, deliveryId, outcome: "prompt_too_long" as const }; + return yield* releaseClaim({ + _tag: "accepted" as const, + deliveryId, + outcome: "prompt_too_long" as const, + }); } // Bound the deliveries one task holds, so steady traffic to a stuck // task cannot pile up parked fibers. - const queueKey = `${task.id}\u0000${task.createdAt}`; - const queued = yield* Ref.modify(webhookQueued, (counts) => { - const count = counts.get(queueKey) ?? 0; - return count >= WEBHOOK_MAX_QUEUED_PER_TASK - ? ([false, counts] as const) - : ([true, new Map(counts).set(queueKey, count + 1)] as const); + yield* refundRejectionSlot; + let queueReserved = false; + const release = Effect.suspend(() => { + if (!queueReserved) return Effect.void; + queueReserved = false; + return Ref.update(webhookQueued, (counts) => { + const next = new Map(counts); + const count = (next.get(queueKey) ?? 1) - 1; + if (count <= 0) next.delete(queueKey); + else next.set(queueKey, count); + return next; + }); + }); + // Serialize immediate reservations with persisted window admission. + const reserveImmediate = Effect.gen(function* () { + yield* checkCapacity; + yield* reserve; + yield* Ref.update(webhookQueued, (counts) => + new Map(counts).set(queueKey, (counts.get(queueKey) ?? 0) + 1), + ); + queueReserved = true; }); - if (!queued) { - yield* observe("queue_full"); + const admitted = yield* admit( + Effect.gen(function* () { + const outcome = yield* acceptImmediateWebhookDedupe( + task, + request, + deliveryId, + DateTime.toEpochMillis(now), + reserveImmediate, + ).pipe(Effect.provideService(SqlClient.SqlClient, sql)); + if (outcome === "duplicate") { + if (yield* takeDedupeLogSlot(task.id, DateTime.toEpochMillis(now))) + yield* log("duplicate", { signatureVerified: signature !== null }); + } else + yield* log("accepted", { + signatureVerified: signature !== null, + missing: rendered.missing, + renderedPrompt: rendered.prompt, + }); + return outcome; + }), + ).pipe(Effect.onError(() => release)); + if (typeof admitted !== "string") { + yield* release; + if (admitted.log) yield* log("rate_limited"); + yield* observe(admitted.outcome); return yield* releaseClaim({ - _tag: "rate_limited" as const, - outcome: "queue_full" as const, - }); + _tag: "rate_limited", + outcome: admitted.outcome, + } as const); + } + if (admitted === "duplicate") { + yield* release; + yield* observe("duplicate"); + return yield* releaseClaim({ + _tag: "accepted", + deliveryId, + outcome: "duplicate", + } as const); } - const release = Ref.update(webhookQueued, (counts) => { - const next = new Map(counts); - const count = (next.get(queueKey) ?? 1) - 1; - if (count <= 0) next.delete(queueKey); - else next.set(queueKey, count); - return next; - }); - yield* log("accepted", { - signatureVerified: signature !== null, - missing: rendered.missing, - renderedPrompt: rendered.prompt, - }).pipe(Effect.onError(() => release)); yield* observe("accepted"); const permit = yield* webhookPermit(task.id); - const runOutcome = (outcome: "started" | "skipped" | "failed") => - Effect.all([ - Effect.annotateCurrentSpan({ "scheduled_task.webhook.run_outcome": outcome }), - Metrics.increment(Metrics.webhookRunsTotal, { outcome }), - ]); - yield* runTask(task, "webhook", { deliveryId, prompt: rendered.prompt }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" - ? runOutcome("failed").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ) - : runOutcome("started"), - ), - Effect.catchTags({ - WebhookDeliverySkipped: (skipped) => - runOutcome("skipped").pipe( - Effect.andThen(markDeliveryFailed(deliveryId, skipped.reason)), - ), - }), - // The log is readable over RPC, so it gets a fixed reason; the - // cause, which can carry request data, stays in the server log. - Effect.catchCause((cause) => - Effect.logWarning("Webhook dispatch failed", { taskId: task.id, cause }).pipe( - Effect.andThen(runOutcome("failed")), - Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), - ), - ), + yield* runWebhookDelivery(task, deliveryId, rendered.prompt).pipe( permit.withPermits(1), - Effect.withSpan("ScheduledTaskService.runWebhookDelivery", { - root: true, - attributes: { - "scheduled_task.id": task.id, - "scheduled_task.webhook.delivery_id": deliveryId, - }, - }), Effect.ensuring(release), Effect.forkIn(serviceScope), ); // Dispatch owns the accepted delivery now; retries must keep its claim. claimAcquired = false; return { _tag: "accepted" as const, deliveryId, outcome: "accepted" as const }; - }).pipe(Effect.onError(() => releaseClaim(undefined))), + }).pipe( + Effect.onError(() => + Effect.all([ + releaseClaim(undefined), + (request.relayDeliveryId === undefined || claimAcquired + ? releaseReplay(deliveryId) + : Effect.void + ).pipe(Effect.ignore), + ]), + ), + ), + ); + }); + + const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { + const now = DateTime.toEpochMillis(yield* localNow); + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; + const tasks = yield* sql<{ task_id: ScheduledTaskId }>` + SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') + `; + yield* Effect.forEach( + tasks, + ({ task_id: taskId }) => + Effect.gen(function* () { + const reserved = yield* Ref.modify(pendingInFlight, (active) => + active.has(taskId) + ? ([false, active] as const) + : ([true, new Set(active).add(taskId)] as const), + ); + if (!reserved) return; + const permit = yield* webhookPermit(taskId); + // One worker per task waits on its permit, then drains persisted rows in order. + // Other tasks remain independent and ticks never reload an active task's payloads. + const dispatch = Effect.gen(function* () { + while (true) { + const [entry] = yield* sql<{ + delivery_id: string; + task_json: string; + request_json: string; + state: string; + }>` + SELECT delivery_id, task_json, request_json, state FROM scheduled_task_webhook_pending + WHERE task_id = ${taskId} AND state IN ('dispatching', 'running', 'completed', 'failed') ORDER BY due_at, rowid LIMIT 1 + `; + if (entry === undefined) return; + const remove = sql`DELETE FROM scheduled_task_webhook_pending WHERE delivery_id = ${entry.delivery_id}`; + if (entry.state === "completed" || entry.state === "failed") { + if (entry.state === "failed") { + yield* markDeliveryFailed(entry.delivery_id, "The run failed to start."); + yield* releaseFailedDelivery(entry.delivery_id); + } + yield* remove; + continue; + } + const prepared = yield* Effect.gen(function* () { + const task = yield* decodePendingTask(entry.task_json); + const request = yield* decodePendingRequest(entry.request_json); + const frameId = Buffer.from(yield* crypto.randomBytes(6)).toString("hex"); + return { task, rendered: renderWebhookPrompt(task.prompt, request, frameId) }; + }).pipe(Effect.exit); + if (Exit.isFailure(prepared)) { + if (Cause.hasInterruptsOnly(prepared.cause)) + return yield* Effect.failCause(prepared.cause); + yield* markDeliveryFailed( + entry.delivery_id, + "The pending webhook payload could not be prepared.", + ); + yield* releaseFailedDelivery(entry.delivery_id); + yield* remove; + continue; + } + const { task, rendered } = prepared.value; + if (rendered.prompt.trim().length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* markDeliveryFailed(entry.delivery_id, PROMPT_TOO_LONG); + yield* releaseFailedDelivery(entry.delivery_id); + } else { + yield* sql`UPDATE scheduled_task_webhook_deliveries SET outcome = 'accepted', error = NULL, rendered_prompt = ${rendered.prompt.slice(0, WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT)}, missing_fields_json = ${encodeMissingFieldsJson(rendered.missing)} WHERE delivery_id = ${entry.delivery_id}`; + if (!(yield* runWebhookDelivery(task, entry.delivery_id, rendered.prompt))) + return; + } + yield* remove; + } + }); + yield* dispatch.pipe( + permit.withPermits(1), + Effect.catch((cause) => + Effect.logWarning("Pending webhook dispatch failed", { cause }), + ), + Effect.ensuring( + Ref.update(pendingInFlight, (active) => { + const next = new Set(active); + next.delete(taskId); + return next; + }), + ), + Effect.withSpan("ScheduledTaskService.dispatchPendingWebhook", { root: true }), + Effect.forkIn(serviceScope), + ); + }), + { discard: true }, ); }); + yield* scheduler.register("webhook-coalescing", dispatchPending()); return ScheduledTaskService.of({ list, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index ae8594a03f0a..65cecbcf4328 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -3,13 +3,17 @@ import * as NodeCrypto from "node:crypto"; import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it } from "@effect/vitest"; -import { ScheduledTaskUpsertInput, SecretRequestError } from "@t3tools/contracts"; +import { + ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryId, + SecretRequestError, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Metric from "effect/Metric"; import * as Queue from "effect/Queue"; import * as EffectScheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; @@ -24,9 +28,94 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as ScheduledTaskService from "./ScheduledTaskService.ts"; +import { renderWebhookPrompt } from "./webhookTemplate.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect( + "preserves omitted dedupe settings, releases duplicate claims, and retains coalesced claims", + () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }; + const { task } = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe } }), + ); + const updated = yield* service.upsert(yield* webhookTaskInput()); + assert.deepEqual( + updated.task.schedule.type === "webhook" && updated.task.schedule.dedupe, + dedupe, + ); + const request = (id: string, bodyText = "{}", relayDeliveryId?: string) => + requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + ...(relayDeliveryId === undefined ? {} : { relayDeliveryId }), + }); + yield* service.triggerWebhook(request("A")); + yield* Queue.take(launches); + yield* service.triggerWebhook(request("B1", '{"group":"B"}')); + for (let i = 0; i < 5; i++) { + const duplicate = yield* service.triggerWebhook(request("A", "{}", `duplicate-${i}`)); + assert.equal(duplicate._tag === "accepted" && duplicate.outcome, "duplicate"); + const coalesced = yield* service.triggerWebhook( + request(`B${i + 2}`, '{"group":"B"}', `coalesced-${i}`), + ); + assert.equal(coalesced._tag === "accepted" && coalesced.outcome, "coalesced"); + } + const claims = yield* sql<{ + relay_delivery_id: string; + }>`SELECT relay_delivery_id FROM scheduled_task_webhook_relay_deliveries ORDER BY relay_delivery_id`; + assert.deepEqual( + claims.map((claim) => claim.relay_delivery_id), + Array.from({ length: 5 }, (_, i) => `coalesced-${i}`), + ); + assert.isTrue( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.every( + (delivery) => delivery.outcome !== "duplicate" && delivery.outcome !== "coalesced", + ), + ); + const fresh = yield* service.triggerWebhook(request("C", "{}", "duplicate-0")); + assert.equal(fresh._tag === "accepted" && fresh.outcome, "accepted"); + yield* Queue.take(launches); + const cleared = yield* service.upsert( + yield* webhookTaskInput({ schedule: { type: "webhook", dedupe: {} } }), + ); + assert.deepEqual( + cleared.task.schedule.type === "webhook" && cleared.task.schedule.dedupe, + {}, + ); + }), + ), +); + +const waitForSpan = (work: Effect.Effect, name: string) => + Effect.gen(function* () { + const done = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === name) Deferred.doneUnsafe(done, Effect.void); + }; + return span; + }, + }); + const result = yield* work.pipe(Effect.withTracer(tracer)); + yield* Deferred.await(done); + return result; + }); + +const drainPendingDispatch = (workers: Map>) => + waitForSpan(workers.get("webhook-coalescing")!, "ScheduledTaskService.dispatchPendingWebhook"); + type LaunchInput = ThreadLaunchService.ThreadLaunchInput; const webhookTaskInput = (overrides: Record = {}) => @@ -70,20 +159,76 @@ const withService = ( readonly sql: SqlClient.SqlClient; readonly secretsByRef: Map; }) => Effect.Effect, - options: { readonly gate?: Deferred.Deferred; readonly relayHookBaseUrl?: string } = {}, + options: { + readonly onFrame?: () => void; + readonly frameGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + readonly completedLaunches?: Set; + readonly failLaunches?: { remaining: number }; + readonly gate?: Deferred.Deferred; + readonly gateForTask?: (input: LaunchInput) => Deferred.Deferred | undefined; + readonly relayHookBaseUrl?: string; + readonly sql?: SqlClient.SqlClient; + readonly workers?: Map>; + } = {}, ) => Effect.gen(function* () { const launches = yield* Queue.unbounded(); const secretsByRef = new Map(); const layerDependencies = Layer.mergeAll( - NodePlatformCrypto.layer, - Scheduler.layer, + options.frameGate === undefined && options.onFrame === undefined + ? NodePlatformCrypto.layer + : Layer.effect( + Crypto.Crypto, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return { + ...crypto, + randomBytes: (size: number) => + Effect.suspend(() => { + if (size === 6) options.onFrame?.(); + return size === 6 && options.frameGate !== undefined + ? Deferred.succeed(options.frameGate.started, undefined).pipe( + Effect.andThen(Deferred.await(options.frameGate.release)), + Effect.andThen(crypto.randomBytes(size)), + ) + : crypto.randomBytes(size); + }), + }; + }), + ).pipe(Layer.provide(NodePlatformCrypto.layer)), + options.workers === undefined + ? Scheduler.layer + : Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + options.workers!.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), Layer.mock(ThreadLaunchService.ThreadLaunchService)({ launch: (input) => - Queue.offer(launches, input).pipe( - Effect.andThen(options.gate ? Deferred.await(options.gate) : Effect.void), - Effect.as({ threadId: "thread-1", resumed: false } as never), - ), + Effect.suspend(() => { + if (options.completedLaunches?.has(input.commandId)) + return Effect.succeed({ threadId: "thread-1", resumed: false } as never); + options.completedLaunches?.add(input.commandId); + return Queue.offer(launches, input).pipe( + Effect.andThen( + (options.gateForTask?.(input) ?? options.gate) + ? Deferred.await((options.gateForTask?.(input) ?? options.gate)!) + : Effect.void, + ), + Effect.andThen( + Effect.sync(() => { + if (options.failLaunches && options.failLaunches.remaining-- > 0) + throw new Error("injected start failure"); + }), + ), + Effect.as({ threadId: "thread-1", resumed: false } as never), + ); + }), }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", @@ -108,8 +253,18 @@ const withService = ( const service = yield* ScheduledTaskService.ScheduledTaskService; const sql = yield* SqlClient.SqlClient; return yield* body({ service, launches, sql, secretsByRef }); - }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); - }).pipe(Effect.provide(SqlitePersistence.layerMemory)); + }).pipe( + Effect.provide( + Layer.fresh(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))), + ), + ); + }).pipe( + Effect.provide( + options.sql === undefined + ? SqlitePersistence.layerMemory + : Layer.succeed(SqlClient.SqlClient, options.sql), + ), + ); it.effect("dispatches exactly the rendered prompt and logs the delivery", () => withService(({ service, launches }) => @@ -132,7 +287,10 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => `scheduled-task:${task.id}:webhook:${result._tag === "accepted" ? result.deliveryId : ""}`, ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); + const { deliveries } = yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + }); assert.equal(deliveries.length, 1); assert.equal(deliveries[0]?.outcome, "accepted"); const { delivery } = yield* service.getWebhookDelivery({ @@ -188,7 +346,11 @@ it.effect("answers not found for a wrong token or unknown hook without logging", assert.equal(wrongToken._tag, "not_found"); const unknown = yield* service.triggerWebhook(requestFor(task, { hookId: "missing" })); assert.equal(unknown._tag, "not_found"); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 0, + ); }), ), ); @@ -253,9 +415,10 @@ it.effect("checks the configured signature and keeps the secret write-only", () ); assert.isTrue(resaved.task.webhook?.hasSecret); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => [delivery.outcome, delivery.signatureVerified], - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => [delivery.outcome, delivery.signatureVerified]); assert.deepEqual(outcomes.toSorted(), [ ["accepted", true], ["rejected_signature", false], @@ -276,48 +439,6 @@ it.effect("logs but does not run deliveries to a disabled task, and refuses run ), ); -it.effect("queues a burst of deliveries instead of dropping them", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - yield* withService( - ({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - const results = yield* Effect.forEach([1, 2, 3], () => - service.triggerWebhook(requestFor(task)), - ); - assert.deepEqual( - results.map((result) => result._tag), - ["accepted", "accepted", "accepted"], - ); - yield* Queue.take(launches); - yield* Deferred.succeed(gate, undefined); - const rest = yield* Effect.all([Queue.take(launches), Queue.take(launches)]); - assert.equal(new Set(rest.map((launch) => launch.commandId)).size, 2); - }), - { gate }, - ); - }), -); - -it.effect("rate limits a hook past 60 deliveries a minute", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const results = yield* Effect.forEach(Array.from({ length: 61 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(results.at(-2)?._tag, "disabled"); - assert.equal(results.at(-1)?._tag, "rate_limited"); - yield* Effect.forEach([1, 2, 3], () => service.triggerWebhook(requestFor(task))); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); - assert.equal(outcomes.filter((outcome) => outcome === "rate_limited").length, 1); - }), - ), -); - it.effect("a save carrying a stale token cannot undo a rotation", () => withService(({ service }) => Effect.gen(function* () { @@ -330,29 +451,6 @@ it.effect("a save carrying a stale token cannot undo a rotation", () => ), ); -it.effect("keeps the newest 50 deliveries when they share a timestamp", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const ids = yield* Effect.forEach(Array.from({ length: 55 }), (_, index) => - service.triggerWebhook(requestFor(task, { query: `n=${index}` })).pipe(Effect.as(index)), - ); - const { deliveries } = yield* service.listWebhookDeliveries({ id: task.id }); - assert.equal(deliveries.length, 50); - const first = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveries[0]!.id, - }); - const last = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveries.at(-1)!.id, - }); - assert.equal(first.delivery.query, `n=${ids.at(-1)}`); - assert.equal(last.delivery.query, "n=5"); - }), - ), -); - it.effect("keeps credential headers and query values out of the delivery log", () => withService(({ service }) => Effect.gen(function* () { @@ -368,7 +466,10 @@ it.effect("keeps credential headers and query values out of the delivery log", ( }, }), ); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const [summary] = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; const { delivery } = yield* service.getWebhookDelivery({ id: task.id, deliveryId: summary!.id, @@ -465,87 +566,6 @@ it.effect("a save without a secret keeps a secret changed after it was read", () ), ); -it.effect("caps deliveries waiting behind a stuck run", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - yield* withService( - ({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - yield* service.triggerWebhook(requestFor(task)); - yield* Queue.take(launches); - const waiting = yield* Effect.forEach(Array.from({ length: 20 }), () => - service.triggerWebhook(requestFor(task)), - ); - assert.equal(waiting.filter((result) => result._tag === "accepted").length, 19); - assert.equal(waiting.at(-1)?._tag, "rate_limited"); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(logged.length, 20); - yield* Deferred.succeed(gate, undefined); - }), - { gate }, - ); - }), -); - -it.effect("logs a body's first 64 KiB by bytes, not characters", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const text = "界".repeat(30_000); - const body = new TextEncoder().encode(text); - yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - const [summary] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - const { delivery } = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: summary!.id, - }); - assert.isTrue(delivery.bodyTruncated); - assert.isAtMost(new TextEncoder().encode(delivery.body).byteLength, 64 * 1024 + 3); - }), - ), -); - -it.effect("counts the prompt as the provider does, without surrounding whitespace", () => - withService(({ service, launches }) => - Effect.gen(function* () { - // Values are framed, so only an empty placeholder can leave padding at the edge. - const { task } = yield* service.upsert( - yield* webhookTaskInput({ prompt: `{{body.none}}${" ".repeat(2_000)}{{body.text}}` }), - ); - const text = JSON.stringify({ text: "x".repeat(119_000) }); - const result = yield* service.triggerWebhook( - requestFor(task, { body: new TextEncoder().encode(text), bodyText: text }), - ); - assert.equal(result._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - -it.effect("does not start a run when the filled-in prompt is too long", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ prompt: "{{body}}" })); - const text = "x".repeat(200_000); - const body = new TextEncoder().encode(text); - const result = yield* service.triggerWebhook(requestFor(task, { body, bodyText: text })); - assert.equal(result._tag, "accepted"); - assert.equal(yield* Queue.size(launches), 0); - const { delivery } = yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: result._tag === "accepted" ? result.deliveryId : ("" as never), - }); - assert.equal(delivery.outcome, "dispatch_failed"); - assert.equal(delivery.error, "The filled-in prompt is too long."); - assert.equal(delivery.renderedPrompt?.length, 64 * 1024); - const ok = yield* service.triggerWebhook(requestFor(task)); - assert.equal(ok._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - it.effect("a held request already delivered directly runs only once", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -559,7 +579,8 @@ it.effect("a held request already delivered directly runs only once", () => assert.equal(direct._tag, "accepted"); assert.deepEqual(replayed, { ...direct, outcome: "duplicate" } as typeof replayed); yield* Queue.take(launches); - const logged = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + const logged = (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })) + .deliveries; assert.equal(logged.length, 1); }), ), @@ -580,7 +601,9 @@ it.effect("relay retries recover failed acceptance without releasing another req const error = yield* service.triggerWebhook(request).pipe(Effect.flip); assert.equal(error.message, "Could not record webhook delivery."); assert.equal(yield* Queue.size(launches), 0); - assert.isEmpty((yield* service.listWebhookDeliveries({ id: task.id })).deliveries); + assert.isEmpty( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries, + ); yield* sql`DROP TRIGGER fail_webhook_delivery_log`; const retried = yield* Effect.all( @@ -606,7 +629,11 @@ it.effect("relay retries recover failed acceptance without releasing another req const replayed = yield* service.triggerWebhook(request); assert.equal(replayed._tag === "accepted" ? replayed.outcome : replayed._tag, "duplicate"); assert.equal(yield* Queue.size(launches), 0); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 1); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id, acceptAnyOutcome: true })).deliveries + .length, + 1, + ); }), ), ); @@ -654,57 +681,6 @@ it.effect("a held request runs once even after the log has trimmed it", () => ), ); -it.effect("a rate-limited held request can run on a later pass", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - yield* Effect.forEach(Array.from({ length: 60 }), () => - service.triggerWebhook(requestFor(task)), - ); - const limited = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); - assert.equal(limited._tag, "rate_limited"); - yield* service.upsert(yield* webhookTaskInput()); - yield* TestClock.adjust("61 seconds"); - const retried = yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "later" })); - assert.equal(retried._tag, "accepted"); - yield* Queue.take(launches); - }), - ), -); - -it.effect("logs a held request at the time the relay received it", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 5 })); - yield* service.triggerWebhook(requestFor(task, { relayDeliveryId: "relay-2", receivedAt })); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(delivery?.receivedAt, receivedAt); - }), - ), -); - -it.effect("a receive time in the future counts as now", () => - withService(({ service, launches }) => - Effect.gen(function* () { - const { task } = yield* service.upsert( - yield* webhookTaskInput({ schedule: { type: "webhook", maxDeliveryAgeMinutes: 30 } }), - ); - const now = yield* DateTime.now; - const result = yield* service.triggerWebhook( - requestFor(task, { - relayDeliveryId: "future", - receivedAt: DateTime.formatIso(DateTime.add(now, { days: 365 })), - }), - ); - assert.equal(result._tag, "accepted"); - yield* Queue.take(launches); - const [delivery] = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; - assert.equal(delivery?.receivedAt, DateTime.formatIso(now)); - }), - ), -); - it.effect("skips a held request older than the task's max age", () => withService(({ service, launches }) => Effect.gen(function* () { @@ -723,41 +699,15 @@ it.effect("skips a held request older than the task's max age", () => requestFor(task, { relayDeliveryId: "fresh", receivedAt: fresh }), ); assert.equal(ok._tag, "accepted"); - const outcomes = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries.map( - (delivery) => delivery.outcome, - ); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((delivery) => delivery.outcome); assert.includeMembers(outcomes, ["expired", "accepted"]); }), ), ); -it.effect("runs a held request of any age when no max age is set", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput()); - const now = yield* DateTime.now; - const result = yield* service.triggerWebhook( - requestFor(task, { - relayDeliveryId: "ancient", - receivedAt: DateTime.formatIso(DateTime.subtract(now, { hours: 23 })), - }), - ); - assert.equal(result._tag, "accepted"); - }), - ), -); - -it.effect("deleting a task removes its delivery log", () => - withService(({ service }) => - Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - yield* service.triggerWebhook(requestFor(task)); - yield* service.delete({ id: task.id }); - assert.equal((yield* service.listWebhookDeliveries({ id: task.id })).deliveries.length, 0); - }), - ), -); - const githubSignature = (secret: string) => `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`; @@ -866,50 +816,868 @@ it.effect("a signature without any secret is refused", () => ), ); -const signatureFor = (secret: string) => - `sha256=${NodeCrypto.createHmac("sha256", secret).update(pullRequestBody).digest("hex")}`; +it.effect("dedupes replay keys per task, persists them, and expires them after seven days", () => + withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const schedule = { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }; + const { task } = yield* service.upsert(yield* webhookTaskInput({ schedule })); + const request = requestFor(task, { headers: { "delivery-id": "A" } }); + const first = yield* service.triggerWebhook(request); + assert.equal(first._tag === "accepted" && first.outcome, "accepted"); + yield* Queue.take(launches); + const replay = yield* service.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + const other = yield* service.upsert( + yield* webhookTaskInput({ id: "scheduled-task:other", schedule }), + ); + yield* service.triggerWebhook(requestFor(other.task, { headers: request.headers })); + yield* Queue.take(launches); + yield* withService( + ({ service: restarted }) => + Effect.gen(function* () { + const replay = yield* restarted.triggerWebhook(request); + assert.equal(replay._tag === "accepted" && replay.outcome, "duplicate"); + }), + { sql, workers: new Map() }, + ); + yield* TestClock.adjust("7 days"); + const expired = yield* service.triggerWebhook(request); + assert.equal(expired._tag === "accepted" && expired.outcome, "accepted"); + yield* Queue.take(launches); + assert.include( + (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome), + "duplicate", + ); + }), + { workers: new Map() }, + ), +); + +it.effect.each([1, 3])( + "coalesces %i deliveries into one run with the latest body, headers and query", + (count) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}} {{headers.version}} {{query.version}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.key}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + for (let value = 1; value <= count; value++) { + const bodyText = JSON.stringify({ key: "B", value }); + yield* service.triggerWebhook( + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { version: String(value) }, + query: `version=${value}`, + }), + ); + if (value < count) yield* TestClock.adjust("2 seconds"); + } + const owner = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.find((delivery) => delivery.outcome === "accepted")!; + assert.isTrue(owner.pendingWindow); + const beforeDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isTrue(beforeDispatch.pendingWindow); + assert.equal(beforeDispatch.body, JSON.stringify({ key: "B", value: count })); + assert.equal(beforeDispatch.headers.version, String(count)); + assert.equal(beforeDispatch.query, `version=${count}`); + assert.equal( + beforeDispatch.bodyBytes, + new TextEncoder().encode(beforeDispatch.body).byteLength, + ); + assert.equal(yield* Queue.size(launches), 0); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust(count === 1 ? "10 seconds" : "6 seconds"); + yield* workers.get("webhook-coalescing")!; + const launched = yield* Queue.take(launches); + assert.equal( + (launched.initialMessage!.text.match(new RegExp(`>${count}<`, "g")) ?? []).length, + 3, + ); + yield* workers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + const outcomes = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries.map((d) => d.outcome); + const afterDispatch = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: owner.id, + acceptAnyOutcome: true, + })).delivery; + assert.isFalse(afterDispatch.pendingWindow); + assert.equal(afterDispatch.body, beforeDispatch.body); + assert.include(afterDispatch.renderedPrompt!, `>${count}<`); + assert.equal(outcomes.filter((o) => o === "accepted").length, 1); + assert.equal(outcomes.filter((o) => o === "coalesced").length, count - 1); + }), + { workers }, + ); + }, +); + +it.effect("recovers a persisted coalescing window after restarting the service", () => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.content-type}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* TestClock.adjust("4 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ launches }) => + Effect.gen(function* () { + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + yield* TestClock.adjust("6 seconds"); + yield* restartedWorkers.get("webhook-coalescing")!; + yield* Queue.take(launches); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); +}); + +it.effect("rolls back replay and pending state when recording a delivery fails", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.content-type}}", + coalesceKey: "{{headers.content-type}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_dedupe_log BEFORE INSERT ON scheduled_task_webhook_deliveries BEGIN SELECT RAISE(FAIL, 'injected'); END`; + yield* service.triggerWebhook(requestFor(task)).pipe(Effect.flip); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + yield* sql`DROP TRIGGER fail_dedupe_log`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* workers.get("webhook-coalescing")!; + yield* Queue.take(launches); + }), + { workers }, + ); +}); + +it.effect("queue-full deliveries release replay keys so a later retry runs", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{headers.delivery-id}}" } }, + }), + ); + const request = (i: number) => + requestFor(task, { headers: { "delivery-id": String(i) } }); + for (let i = 0; i < 20; i++) yield* service.triggerWebhook(request(i)); + yield* Queue.take(launches); + const full = yield* service.triggerWebhook(request(20)); + assert.equal(full._tag === "rate_limited" && full.outcome, "queue_full"); + yield* Deferred.succeed(gate, undefined); + for (let i = 1; i < 20; i++) yield* Queue.take(launches); + const retry = yield* service.triggerWebhook(request(20)); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate }, + ); + }), +); -const deliveriesCounted = (outcome: string, source: "relay" | "direct") => - Metric.snapshot.pipe( - Effect.map((snapshots) => { - const found = snapshots.find( - (snapshot) => - snapshot.id === "t3_webhook_deliveries_total" && - snapshot.attributes?.outcome === outcome && - snapshot.attributes?.source === source, +it.effect.each(["task_json", "request_json"] as const)( + "removes corrupt pending %s instead of retrying forever", + (column) => { + const workers = new Map>(); + return withService( + ({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{body}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + yield* sql`UPDATE scheduled_task_webhook_pending SET ${sql(column)} = 'broken'`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal( + (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!.outcome, + "dispatch_failed", + ); + yield* workers.get("webhook-coalescing")!; + }), + { workers }, + ); + }, +); + +it.effect( + "restarting after a coalesced launch before pending deletion does not launch or count it twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const accepted = yield* service.triggerWebhook(requestFor(task)); + yield* sql`CREATE TRIGGER fail_pending_delete BEFORE DELETE ON scheduled_task_webhook_pending BEGIN SELECT RAISE(FAIL,'crash after launch'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const launch = yield* Queue.take(launches); + assert.equal( + launch.commandId, + `scheduled-task:${task.id}:webhook:${accepted._tag === "accepted" && accepted.deliveryId}`, + ); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql`SELECT * FROM scheduled_task_webhook_pending WHERE state = 'completed'`) + .length, + 1, + ); + yield* sql`DROP TRIGGER fail_pending_delete`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: secondLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(secondLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers }, + ); + }, +); + +it.effect("guards bad-signature floods separately from valid run admission", () => + withService(({ service, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const results = yield* Effect.forEach(Array.from({ length: 75 }), () => + service.triggerWebhook(requestFor(task)), ); - return found?.type === "Counter" ? Number(found.state.count) : 0; + assert.equal(results.filter((result) => result._tag === "rejected_signature").length, 60); + assert.equal(results.filter((result) => result._tag === "rate_limited").length, 15); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_deliveries`).length, 51); }), - ); + ), +); -it.effect("keeps delivery attributes on the webhook operation span", () => - withService(({ service }) => +it.effect("start failures release both replay keys and relay claims for redelivery", () => + withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { type: "webhook", dedupe: { replayKey: "{{body}}" } }, + }), + ); + const request = requestFor(task, { relayDeliveryId: "failed-start" }); + yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT * FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.lastRunStatus, "succeeded"); + }), + { failLaunches: { remaining: 1 } }, + ), +); + +it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => + withService(({ service, sql, launches }) => Effect.gen(function* () { - const { task } = yield* service.upsert(yield* webhookTaskInput({ enabled: false })); - const spans: Tracer.NativeSpan[] = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - const receivedAt = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { seconds: 1 })); - yield* service - .triggerWebhook(requestFor(task, { receivedAt, relayDeliveryId: "traced-delivery" })) - .pipe(Effect.withTracer(tracer)); - - const span = spans.find((entry) => entry.name === "ScheduledTaskService.triggerWebhook"); - assert.isDefined(span); - assert.equal(span?.attributes.get("scheduled_task.id"), task.id); - assert.equal(span?.attributes.get("scheduled_task.webhook.outcome"), "disabled"); - assert.equal(span?.attributes.get("scheduled_task.webhook.source"), "relay"); - assert.equal(span?.attributes.get("scheduled_task.webhook.held_ms"), 1_000); + const { task } = yield* service.upsert(yield* webhookTaskInput()); + yield* service.triggerWebhook(requestFor(task)); + yield* Queue.take(launches); + for (const outcome of ["filtered", "duplicate"]) { + yield* sql`WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 20) + INSERT INTO scheduled_task_webhook_deliveries + SELECT ${outcome} || n.value, d.task_id, d.received_at, d.method, d.query, d.headers_json, d.body, + d.body_bytes, d.body_truncated, ${outcome}, d.signature_verified, d.missing_fields_json, d.rendered_prompt, d.error + FROM n CROSS JOIN (SELECT * FROM scheduled_task_webhook_deliveries WHERE outcome = 'accepted' LIMIT 1) d`; + } + for (let i = 0; i < 59; i++) { + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "accepted"); + yield* Queue.take(launches); + } + const counts = yield* sql<{ + outcome: string; + count: number; + }>`SELECT outcome, COUNT(*) AS count FROM scheduled_task_webhook_deliveries GROUP BY outcome ORDER BY outcome`; + assert.deepEqual(counts, [ + { outcome: "accepted", count: 50 }, + { outcome: "duplicate", count: 20 }, + { outcome: "filtered", count: 20 }, + ]); + }), + ), +); + +it.effect( + "a valid request refunds its own reservation without expiring an interleaved rejection", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + signature: { header: "signature", encoding: "hex", prefix: "", secret: "secret" }, + }, + }), + ); + const signature = NodeCrypto.createHmac("sha256", "secret") + .update(pullRequestBody) + .digest("hex"); + const valid = yield* service + .triggerWebhook(requestFor(task, { headers: { signature } })) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* TestClock.adjust("30 seconds"); + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + yield* Deferred.succeed(release, undefined); + assert.equal((yield* Fiber.join(valid))._tag, "accepted"); + yield* Queue.take(launches); + // Only the valid reservation would have expired now; the rejection must still count. + yield* TestClock.adjust("31 seconds"); + for (let i = 0; i < 59; i++) + assert.equal( + (yield* service.triggerWebhook(requestFor(task)))._tag, + "rejected_signature", + ); + assert.equal((yield* service.triggerWebhook(requestFor(task)))._tag, "rate_limited"); + }), + { frameGate: { started, release } }, + ); + }), +); + +it.effect("keeps replay keys when dispatch succeeds but completion bookkeeping fails", () => + withService(({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { replayKey: "{{body}}" }, + }, + }), + ); + yield* sql`CREATE TRIGGER fail_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* waitForSpan( + service.triggerWebhook(requestFor(task)), + "ScheduledTaskService.runWebhookDelivery", + ); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 1); + yield* sql`DROP TRIGGER fail_completion`; + const retry = yield* service.triggerWebhook(requestFor(task)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); }), ), ); -it.effect("counts each handled request by what happened to it", () => +it.effect.each([119_000, 120_000])( + "bounds coalesced prompt logs and checks trimmed length for %i body characters", + (length) => { + const workers = new Map>(); + return withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: `{{body.absent}}${" ".repeat(10_000)}{{body.text}}`, + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const bodyText = JSON.stringify({ group: "B", text: "x".repeat(length) }); + const accepted = yield* service.triggerWebhook( + requestFor(task, { bodyText, body: new TextEncoder().encode(bodyText) }), + ); + assert.equal(accepted._tag, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + const delivery = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries[0]!; + if (length === 119_000) { + const launch = yield* Queue.take(launches); + assert.isAbove(launch.initialMessage!.text.length, 120_000); + assert.equal(delivery.outcome, "accepted"); + const detail = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: delivery.id, + }); + assert.equal(detail.delivery.renderedPrompt!.length, 64 * 1024); + } else { + assert.equal(yield* Queue.size(launches), 0); + assert.equal(delivery.outcome, "dispatch_failed"); + assert.equal(delivery.error, "The filled-in prompt is too long."); + } + }), + { workers }, + ); + }, +); + +it.effect.each([1, 11])( + "keeps relay claims for %i coalesced updates after their window closes", + (count) => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const request = (value: number) => { + const bodyText = JSON.stringify({ group: "B", value }); + return requestFor(task, { + relayDeliveryId: `coalesced-relay-${value}`, + bodyText, + body: new TextEncoder().encode(bodyText), + }); + }; + yield* service.triggerWebhook(request(0)); + for (let i = 1; i <= count; i++) { + const result = yield* service.triggerWebhook(request(i)); + assert.equal(result._tag === "accepted" && result.outcome, "coalesced"); + } + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + count + 1, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, `>${count}<`); + const retry = yield* service.triggerWebhook(request(count)); + assert.equal(retry._tag === "accepted" && retry.outcome, "duplicate"); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); + }, +); + +it.effect("failed windows release every member's replay key and relay claim", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => { + const bodyText = JSON.stringify({ group: "same", value: id }); + return requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id }, + relayDeliveryId: id, + }); + }; + yield* service.triggerWebhook(request("A")); + for (let i = 0; i < 12; i++) yield* service.triggerWebhook(request(`B${i}`)); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 13, + ); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const retry = yield* service.triggerWebhook(request("B11")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "B11"); + assert.equal((yield* service.list()).tasks[0]!.runCount, 2); + }), + { workers, failLaunches: { remaining: 1 } }, + ); +}); + +it.effect( + "recovery finishes failed-window claim cleanup without counting the attempt twice", + () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string) => + requestFor(task, { headers: { "delivery-id": id }, relayDeliveryId: id }); + yield* service.triggerWebhook(request("A")); + yield* service.triggerWebhook(request("B")); + yield* sql`CREATE TRIGGER fail_claim_cleanup BEFORE DELETE ON scheduled_task_webhook_relay_deliveries + BEGIN SELECT RAISE(FAIL, 'cleanup unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal( + (yield* sql<{ state: string }>`SELECT state FROM scheduled_task_webhook_pending`)[0]! + .state, + "failed", + ); + yield* sql`DROP TRIGGER fail_claim_cleanup`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: restartedLaunches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(restartedLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, + 0, + ); + const retry = yield* restarted.triggerWebhook(request("B")); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(restartedLaunches); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers }, + ); + }), + { workers, failLaunches: { remaining: 1 } }, + ); + }, +); + +it.effect("dispatches an overdue window exactly once after its deadline passed while stopped", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const task = yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{body}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + yield* service.triggerWebhook(requestFor(task)); + assert.equal(yield* Queue.size(launches), 0); + return task; + }), + { sql, workers: new Map() }, + ); + // The first service scope is closed before the deadline advances. + yield* TestClock.adjust("30 seconds"); + const restartedWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* drainPendingDispatch(restartedWorkers); + yield* Queue.take(launches); + for (let i = 0; i < 3; i++) yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + }), + { sql, workers: restartedWorkers }, + ); + const secondRestartWorkers = new Map>(); + yield* withService( + ({ service, launches }) => + Effect.gen(function* () { + yield* secondRestartWorkers.get("webhook-coalescing")!; + assert.equal(yield* Queue.size(launches), 0); + assert.equal( + (yield* service.list()).tasks.find((entry) => entry.id === task.id)!.runCount, + 1, + ); + }), + { sql, workers: secondRestartWorkers }, + ); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + +it.effect("removes a window with a render defect and dispatches the task's next window", () => { + const workers = new Map>(); + return withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.deep}}", + schedule: { + type: "webhook", + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{headers.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, bodyText: string) => + requestFor(task, { + bodyText, + body: new TextEncoder().encode(bodyText), + headers: { "delivery-id": id, group: id }, + relayDeliveryId: id, + }); + const bad = request( + "bad-render", + '{"deep":' + "[".repeat(20000) + "0" + "]".repeat(20000) + "}", + ); + assert.throws(() => renderWebhookPrompt(task.prompt, bad, "frame"), RangeError); + const accepted = yield* service.triggerWebhook(bad); + assert.equal(accepted._tag, "accepted"); + yield* service.triggerWebhook(request("good-render", '{"deep":"next window"}')); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "next window"); + assert.equal(yield* Queue.size(launches), 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys WHERE delivery_id = 'delivery:relay:bad-render'`) + .length, + 0, + ); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries WHERE relay_delivery_id = 'bad-render'`) + .length, + 0, + ); + const failed = (yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:relay:bad-render"), + acceptAnyOutcome: true, + })).delivery; + assert.equal(failed.outcome, "dispatch_failed"); + assert.equal(failed.error, "The pending webhook payload could not be prepared."); + yield* workers.get("webhook-coalescing")!; + assert.equal((yield* service.list()).tasks[0]!.runCount, 1); + }), + { workers }, + ); +}); + +it.effect.each([false, true])( + "counts an interrupted immediate run beside an unrelated running window (corrupt task: %s)", + (corrupt) => { + const workers = new Map>(); + const completedLaunches = new Set(); + return Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + dedupe: { coalesceKey: "{{headers.group}}", coalesceWindowSeconds: 10 }, + }, + }), + ); + const window = yield* service.triggerWebhook( + requestFor(task, { headers: { group: "old" }, bodyText: '{"value":"old"}' }), + ); + yield* sql`CREATE TRIGGER fail_old_completion BEFORE UPDATE ON scheduled_tasks + WHEN NEW.last_run_status = 'succeeded' BEGIN SELECT RAISE(FAIL, 'completion unavailable'); END`; + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + yield* Queue.take(launches); + yield* sql`DROP TRIGGER fail_old_completion`; + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE state = 'running'`) + .length, + 1, + ); + const immediate = yield* service.triggerWebhook( + requestFor(task, { bodyText: '{"value":"immediate"}' }), + ); + yield* Queue.take(launches); + const [before] = yield* sql<{ + last_run_webhook_delivery_id: string; + model_selection_json: string; + run_count: number; + }>`SELECT last_run_webhook_delivery_id, model_selection_json, run_count FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal( + before!.last_run_webhook_delivery_id, + immediate._tag === "accepted" ? immediate.deliveryId : "", + ); + assert.notEqual( + before!.last_run_webhook_delivery_id, + window._tag === "accepted" ? window.deliveryId : "", + ); + assert.equal(before!.run_count, 0); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = 'broken' WHERE task_id = ${task.id}`; + const restartedWorkers = new Map>(); + yield* withService( + ({ service: restarted, launches: recoveredLaunches }) => + Effect.gen(function* () { + const [recovered] = yield* sql<{ + run_count: number; + last_run_status: string; + }>`SELECT run_count, last_run_status FROM scheduled_tasks WHERE task_id = ${task.id}`; + assert.equal(recovered!.run_count, 1); + assert.equal(recovered!.last_run_status, "failed"); + if (corrupt) + yield* sql`UPDATE scheduled_tasks SET model_selection_json = ${before!.model_selection_json} WHERE task_id = ${task.id}`; + yield* drainPendingDispatch(restartedWorkers); + assert.equal(yield* Queue.size(recoveredLaunches), 0); + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + assert.equal( + (yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, + 0, + ); + yield* restartedWorkers.get("webhook-coalescing")!; + assert.equal((yield* restarted.list()).tasks[0]!.runCount, 2); + }), + { sql, workers: restartedWorkers, completedLaunches }, + ); + }), + { + workers, + completedLaunches, + gateForTask: (input) => + input.initialMessage?.text.includes(">immediate<") ? gate : undefined, + }, + ); + }); + }, +); + +it.effect("checks filters after the signature and max age, and runs only matching deliveries", () => withService(({ service, launches }) => Effect.gen(function* () { const { task } = yield* service.upsert( @@ -920,36 +1688,204 @@ it.effect("counts each handled request by what happened to it", () => header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=", - secret: "github-secret", + secret: "s3cret", }, + maxDeliveryAgeMinutes: 30, + filters: [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ], }, }), ); - const before = { - accepted: yield* deliveriesCounted("accepted", "direct"), - rejected: yield* deliveriesCounted("rejected_signature", "direct"), - notFound: yield* deliveriesCounted("not_found", "direct"), - relayAccepted: yield* deliveriesCounted("accepted", "relay"), - duplicate: yield* deliveriesCounted("duplicate", "relay"), - }; - const signed = { - "content-type": "application/json", - "x-hub-signature-256": signatureFor("github-secret"), + const delivery = ( + event: string, + action: string, + options: { readonly signed?: boolean; readonly receivedAt?: string } = {}, + ) => { + const body = new TextEncoder().encode( + JSON.stringify({ action, pull_request: { url: "https://github.com/org/repo/pull/45" } }), + ); + const signature = `sha256=${NodeCrypto.createHmac("sha256", "s3cret").update(body).digest("hex")}`; + return requestFor(task, { + headers: { + "content-type": "application/json", + "x-github-event": event, + ...(options.signed === false ? {} : { "x-hub-signature-256": signature }), + }, + body, + bodyText: new TextDecoder().decode(body), + ...(options.receivedAt === undefined + ? {} + : { relayDeliveryId: "old", receivedAt: options.receivedAt }), + }); }; - yield* service.triggerWebhook(requestFor(task, { headers: signed })); - yield* Queue.take(launches); - yield* service.triggerWebhook(requestFor(task)); - yield* service.triggerWebhook(requestFor(task, { token: "wrong" })); - const relayed = requestFor(task, { headers: signed, relayDeliveryId: "relay-1" }); - yield* service.triggerWebhook(relayed); + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.subtract(now, { minutes: 31 })); + + // A request failing a filter is still refused first for its signature or age. + const unsigned = yield* service.triggerWebhook(delivery("push", "opened", { signed: false })); + assert.equal(unsigned._tag, "rejected_signature"); + const tooOld = yield* service.triggerWebhook(delivery("push", "opened", { receivedAt: old })); + assert.equal(tooOld._tag, "expired"); + + const wrongEvent = yield* service.triggerWebhook(delivery("push", "opened")); + const wrongAction = yield* service.triggerWebhook(delivery("pull_request", "closed")); + assert.deepInclude(wrongEvent, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(wrongAction, { _tag: "accepted", outcome: "filtered" }); + assert.equal(yield* Queue.size(launches), 0); + const [listed] = (yield* service.list()).tasks; + assert.equal(listed?.runCount, 0); + assert.equal(listed?.lastRunStatus, "never"); + + const matched = yield* service.triggerWebhook(delivery("pull_request", "opened")); + assert.deepInclude(matched, { _tag: "accepted", outcome: "accepted" }); + assert.include((yield* Queue.take(launches)).initialMessage?.text ?? "", "pull/45"); + + const deliveries = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + const filtered = deliveries + .filter((entry) => entry.outcome === "filtered") + .map((entry) => [entry.signatureVerified, entry.error]); + assert.sameDeepMembers(filtered, [ + [true, "Filter 1 (headers.x-github-event) did not match."], + [true, "Filter 2 (body.action) did not match."], + ]); + }), + ), +); + +const pullRequestFilterInput = () => + webhookTaskInput({ + schedule: { + type: "webhook", + filters: [{ path: "headers.x-github-event", op: "equals", value: "pull_request" }], + }, + }); +const githubEvent = (event: string) => ({ + headers: { "content-type": "application/json", "x-github-event": event }, +}); + +it.effect("a filtered relay delivery releases its claim, so a redelivery is evaluated again", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + const held = { relayDeliveryId: "relay-filtered" }; + const first = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + const again = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("push") }), + ); + assert.deepInclude(first, { _tag: "accepted", outcome: "filtered" }); + assert.deepInclude(again, { _tag: "accepted", outcome: "filtered" }); + const ran = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(ran, { _tag: "accepted", outcome: "accepted" }); yield* Queue.take(launches); - yield* service.triggerWebhook(relayed); + const replayed = yield* service.triggerWebhook( + requestFor(task, { ...held, ...githubEvent("pull_request") }), + ); + assert.deepInclude(replayed, { _tag: "accepted", outcome: "duplicate" }); + }), + ), +); - assert.equal((yield* deliveriesCounted("accepted", "direct")) - before.accepted, 1); - assert.equal((yield* deliveriesCounted("rejected_signature", "direct")) - before.rejected, 1); - assert.equal((yield* deliveriesCounted("not_found", "direct")) - before.notFound, 1); - assert.equal((yield* deliveriesCounted("accepted", "relay")) - before.relayAccepted, 1); - assert.equal((yield* deliveriesCounted("duplicate", "relay")) - before.duplicate, 1); +it.effect("leaves filtered rows out of the list for clients that only decode older outcomes", () => + withService(({ service, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* pullRequestFilterInput()); + yield* service.triggerWebhook(requestFor(task, githubEvent("push"))); + yield* service.triggerWebhook(requestFor(task, githubEvent("pull_request"))); + yield* Queue.take(launches); + const legacy = (yield* service.listWebhookDeliveries({ id: task.id })).deliveries; + assert.deepEqual( + legacy.map((delivery) => delivery.outcome), + ["accepted"], + ); + const current = (yield* service.listWebhookDeliveries({ + id: task.id, + acceptAnyOutcome: true, + })).deliveries; + assert.sameMembers( + current.map((delivery) => delivery.outcome), + ["accepted", "filtered"], + ); }), ), ); + +it.effect("filters run before replay and coalescing without claiming ignored deliveries", () => { + const workers = new Map>(); + return withService( + ({ service, launches, sql }) => + Effect.gen(function* () { + const { task } = yield* service.upsert( + yield* webhookTaskInput({ + prompt: "{{body.value}}", + schedule: { + type: "webhook", + filters: [{ path: "body.run", op: "equals", value: "true" }], + dedupe: { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.group}}", + coalesceWindowSeconds: 10, + }, + }, + }), + ); + const request = (id: string, run: boolean, value: string) => { + const bodyText = JSON.stringify({ run, group: "B", value }); + return requestFor(task, { + headers: { "delivery-id": id }, + bodyText, + body: new TextEncoder().encode(bodyText), + relayDeliveryId: id, + }); + }; + const ignored = yield* service.triggerWebhook(request("A", false, "ignored")); + assert.deepInclude(ignored, { _tag: "accepted", outcome: "filtered" }); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_pending`).length, 0); + assert.equal((yield* sql`SELECT 1 FROM scheduled_task_webhook_relay_deliveries`).length, 0); + const filteredId = ignored._tag === "accepted" ? ignored.deliveryId : assert.fail(); + const oldClient = yield* service + .getWebhookDelivery({ id: task.id, deliveryId: filteredId }) + .pipe(Effect.result); + assert.equal(oldClient._tag, "Failure"); + const currentClient = yield* service.getWebhookDelivery({ + id: task.id, + deliveryId: filteredId, + acceptAnyOutcome: true, + }); + assert.equal(currentClient.delivery.outcome, "filtered"); + assert.deepInclude(yield* service.triggerWebhook(request("A", true, "first")), { + _tag: "accepted", + outcome: "accepted", + }); + assert.deepInclude( + yield* service.triggerWebhook({ + ...request("A", false, "ignored replay"), + relayDeliveryId: "ignored-replay", + }), + { _tag: "accepted", outcome: "filtered" }, + ); + assert.deepInclude(yield* service.triggerWebhook(request("B", false, "ignored update")), { + _tag: "accepted", + outcome: "filtered", + }); + assert.deepInclude(yield* service.triggerWebhook(request("B", true, "latest")), { + _tag: "accepted", + outcome: "coalesced", + }); + yield* TestClock.adjust("10 seconds"); + yield* drainPendingDispatch(workers); + assert.include((yield* Queue.take(launches)).initialMessage!.text, "latest"); + assert.equal(yield* Queue.size(launches), 0); + }), + { workers }, + ); +}); diff --git a/apps/server/src/scheduledTasks/webhookDedupe.ts b/apps/server/src/scheduledTasks/webhookDedupe.ts new file mode 100644 index 000000000000..21d40b25ac5e --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookDedupe.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Keys need a deterministic digest, not random bytes. +import * as NodeCrypto from "node:crypto"; +import { ScheduledTask, ScheduledTaskError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { renderWebhookPrompt, type WebhookRequest } from "./webhookTemplate.ts"; + +function key(template: string | undefined, request: WebhookRequest): string { + if (template === undefined) return ""; + const rendered = renderWebhookPrompt(template, request, "", true); + return rendered.missing.length > 0 || rendered.prompt === "" + ? "" + : NodeCrypto.createHash("sha256").update(rendered.prompt).digest("hex"); +} + +export const releaseWebhookReplay = Effect.fn("ScheduledTaskService.releaseWebhookReplay")( + function* (deliveryId: string) { + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE delivery_id = ${deliveryId}`; + }, +); + +const hasReplayKey = Effect.fn(function* (taskId: string, replayKey: string, now: number) { + const sql = yield* SqlClient.SqlClient; + return ( + replayKey !== "" && + (yield* sql`SELECT 1 FROM scheduled_task_webhook_replay_keys + WHERE task_id = ${taskId} AND replay_key = ${replayKey} AND expires_at > ${now}`).length > 0 + ); +}); +const claimReplayKey = Effect.fn(function* ( + taskId: string, + replayKey: string, + deliveryId: string, + now: number, +) { + if (replayKey === "") return; + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM scheduled_task_webhook_replay_keys WHERE expires_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_replay_keys (task_id, replay_key, delivery_id, expires_at) + VALUES (${taskId}, ${replayKey}, ${deliveryId}, ${now + 7 * 24 * 60 * 60 * 1000})`; +}); + +export const acceptWebhookDedupe = Effect.fn("ScheduledTaskService.acceptWebhookDedupe")(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, + reserveCoalescedLog: Effect.Effect, + checkCapacity: Effect.Effect, + replaceDeliveryRequest: (ownerId: string) => Effect.Effect, +) { + const sql = yield* SqlClient.SqlClient; + const dedupe = task.schedule.type === "webhook" ? task.schedule.dedupe : undefined; + if (dedupe === undefined) return "run" as const; + const replayKey = key(dedupe.replayKey, request); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + const coalesceKey = key(dedupe.coalesceKey, request); + if (coalesceKey === "") return "run" as const; + const pending = yield* sql<{ + delivery_id: string; + }>`SELECT delivery_id FROM scheduled_task_webhook_pending + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at > ${now}`; + const logCoalesced = pending.length > 0 ? yield* reserveCoalescedLog : true; + if (pending.length === 0) { + yield* checkCapacity; + yield* reserve; + } + // Coalesced requests retain their validation slot, bounding writes without dropping accepted payloads. + yield* claimReplayKey(task.id, replayKey, pending[0]?.delivery_id ?? deliveryId, now); + const requestJson = yield* encodePendingRequest({ + method: request.method, + path: request.path, + query: request.query, + headers: request.headers, + bodyText: request.bodyText, + }); + if (pending.length > 0) { + yield* sql`UPDATE scheduled_task_webhook_pending SET request_json = ${requestJson}, + delivery_ids_json = json_insert(delivery_ids_json, '$[#]', ${deliveryId}) WHERE delivery_id = ${pending[0]!.delivery_id}`; + yield* replaceDeliveryRequest(pending[0]!.delivery_id); + return logCoalesced ? ("coalesced" as const) : ("coalesced_unlogged" as const); + } + // Expired windows are immutable, even if the scheduler has not dispatched them yet. + yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' + WHERE task_id = ${task.id} AND coalesce_key = ${coalesceKey} AND state = 'pending' AND due_at <= ${now}`; + yield* sql`INSERT INTO scheduled_task_webhook_pending (delivery_id, task_id, coalesce_key, due_at, task_json, request_json, delivery_ids_json) + VALUES (${deliveryId}, ${task.id}, ${coalesceKey}, ${now + (dedupe.coalesceWindowSeconds ?? 120) * 1000}, ${yield* encodePendingTask(task)}, ${requestJson}, json_array(${deliveryId}))`; + return "pending" as const; +}); + +export const acceptImmediateWebhookDedupe = Effect.fn( + "ScheduledTaskService.acceptImmediateWebhookDedupe", +)(function* ( + task: ScheduledTask, + request: WebhookRequest, + deliveryId: string, + now: number, + reserve: Effect.Effect, +) { + const replayKey = key( + task.schedule.type === "webhook" ? task.schedule.dedupe?.replayKey : undefined, + request, + ); + if (yield* hasReplayKey(task.id, replayKey, now)) return "duplicate" as const; + yield* reserve; + yield* claimReplayKey(task.id, replayKey, deliveryId, now); + return "run" as const; +}); + +const PendingTaskJson = Schema.fromJsonString(ScheduledTask); +const encodePendingTask = Schema.encodeEffect(PendingTaskJson); +export const decodePendingTask = Schema.decodeUnknownEffect(PendingTaskJson); +const PendingRequestJson = Schema.fromJsonString( + Schema.Struct({ + method: Schema.String, + path: Schema.String, + query: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + bodyText: Schema.String, + }), +); +const encodePendingRequest = Schema.encodeEffect(PendingRequestJson); +export const decodePendingRequest = Schema.decodeUnknownEffect(PendingRequestJson); +export const dedupeError = (cause: unknown) => + new ScheduledTaskError({ message: "Could not persist webhook dedupe state.", cause }); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts new file mode 100644 index 000000000000..6f880ef7cebe --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -0,0 +1,49 @@ +import type { ScheduledTaskWebhookFilter } from "@t3tools/contracts"; + +import { webhookExpressionResolver, type WebhookRequest } from "./webhookTemplate.ts"; + +export interface FailedWebhookFilter { + readonly index: number; + readonly path: string; +} + +function scalarText(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return undefined; +} + +/** Placeholders render null as missing, so filters treat it the same way. */ +function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + switch (filter.op) { + case "equals": + return text === filter.value; + case "notEquals": + return text !== filter.value; + case "in": + return filter.value.includes(text); + case "startsWith": + return text.startsWith(filter.value); + } +} + +/** The first filter the request fails, or null when every filter matches. */ +export function findFailedWebhookFilter( + filters: ReadonlyArray, + request: WebhookRequest, +): FailedWebhookFilter | null { + if (filters.length === 0) return null; + const resolve = webhookExpressionResolver(request); + for (const [index, filter] of filters.entries()) { + if (!matchesWebhookFilter(filter, resolve(filter.path))) return { index, path: filter.path }; + } + return null; +} + +/** Filters are numbered from 1, in the order the settings form lists them. */ +export function describeFailedWebhookFilter(failed: FailedWebhookFilter): string { + return `Filter ${failed.index + 1} (${failed.path}) did not match.`; +} diff --git a/apps/server/src/scheduledTasks/webhookRoute.test.ts b/apps/server/src/scheduledTasks/webhookRoute.test.ts index 31261660c623..5ef605be0ab4 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.test.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.test.ts @@ -113,35 +113,6 @@ const post = ( ) => new Request(`http://env.local${path}`, { method: "POST", body, headers }); describe("webhook route", () => { - it("passes the raw request to the service and answers 202 with the delivery id", async () => { - let received: WebhookTriggerRequest | undefined; - const { handler, dispose } = handlerFor((request) => { - received = request; - return Effect.succeed({ - _tag: "accepted", - deliveryId: ScheduledTaskWebhookDeliveryId.make("delivery:1"), - outcome: "accepted", - }); - }); - try { - const response = await handler( - post("/api/hooks/scheduled-task%3Ahook/tok?x=1", '{"a":1}', { - "Content-Type": "application/json", - "X-GitHub-Event": "push", - }), - ); - expect(response.status).toBe(202); - expect(await response.json()).toEqual({ deliveryId: "delivery:1" }); - expect(received?.hookId).toBe("scheduled-task:hook"); - expect(received?.token).toBe("tok"); - expect(received?.query).toBe("x=1"); - expect(received?.headers["x-github-event"]).toBe("push"); - expect(received?.bodyText).toBe('{"a":1}'); - } finally { - await dispose(); - } - }); - it.live("trusts the relay's delivery id and receive time only with its signed proof", () => Effect.gen(function* () { const received: Array = []; @@ -222,6 +193,7 @@ describe("webhook route", () => { [{ _tag: "accepted", deliveryId, outcome: "accepted" }, 202, "accepted"], [{ _tag: "accepted", deliveryId, outcome: "duplicate" }, 202, "duplicate"], [{ _tag: "accepted", deliveryId, outcome: "prompt_too_long" }, 202, "prompt_too_long"], + [{ _tag: "accepted", deliveryId, outcome: "filtered" }, 202, "filtered"], [{ _tag: "not_found" }, 404, "not_found"], [{ _tag: "rejected_signature" }, 401, "rejected_signature"], [{ _tag: "disabled" }, 409, "disabled"], @@ -235,39 +207,13 @@ describe("webhook route", () => { const response = await handler(post("/api/hooks/id/tok", "{}")); expect(response.status).toBe(status); expect(response.headers.get("x-t3-hook-outcome")).toBe(outcome); + if (status === 429) expect(await response.json()).toEqual({ error: outcome }); } finally { await dispose(); } } }); - it.live("joins the relay's trace only for requests the relay forwarded", () => - Effect.gen(function* () { - const parents: Array = []; - const { handler, dispose } = handlerFor(() => - Effect.gen(function* () { - const span = yield* Effect.currentParentSpan.pipe(Effect.option); - parents.push(span._tag === "Some" ? span.value.traceId : undefined); - return { _tag: "not_found" } as const; - }), - ); - const send = (headers: Record) => - Effect.promise(() => handler(post("/api/hooks/id/tok", "{}", headers))); - const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; - const receivedAt = "2026-10-04T10:00:00.000Z"; - const relayHeaders = { - "x-t3-relay-delivery-id": "relay-1", - "x-t3-relay-received-at": receivedAt, - }; - const proof = yield* relayProof({ deliveryId: "relay-1", receivedAt, hookId: "id" }); - yield* send({ ...relayHeaders, traceparent, "x-t3-relay-delivery": proof }); - yield* send({ ...relayHeaders, traceparent }); - yield* send({ traceparent }); - yield* Effect.promise(() => dispose()); - expect(parents).toEqual(["0af7651916cd43dd8448eb211c80319c", undefined, undefined]); - }), - ); - it("rejects oversized bodies and malformed paths before reaching the service", async () => { let calls = 0; const { handler, dispose } = handlerFor(() => { diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index ce67fa157d33..2566349d273b 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -118,7 +118,7 @@ export const layer = HttpApiBuilder.group( case "disabled": return json(409, { error: "hook_disabled" }, "disabled"); case "rate_limited": - return json(429, { error: "rate_limited" }, result.outcome); + return json(429, { error: result.outcome }, result.outcome); case "expired": return json(410, { error: "delivery_too_old" }, "expired"); case "error": diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index 6f8f3474fb51..b5ac2e69aa38 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -77,34 +77,6 @@ describe("renderWebhookPrompt", () => { assert.deepEqual(collectComposerContextReferences(rendered.prompt), []); }); - it("reads headers case-insensitively, query parameters and array indexes", () => { - const rendered = renderWebhookPrompt( - "{{ headers.X-GitHub-Event }} #{{body.pull_request.number}} {{body.pull_request.labels.0}} {{query.source}}", - githubPullRequest, - "id", - ); - assert.equal( - rendered.prompt, - `pull_request #45 bug github${NOTE}`, - ); - }); - - it("renders objects as JSON and the raw body and request on request", () => { - const rendered = renderWebhookPrompt( - "{{body.pull_request.labels}}|{{body}}", - githubPullRequest, - "id", - ); - assert.equal( - rendered.prompt, - `[\n "bug"\n]|${githubPullRequest.bodyText}${NOTE}`, - ); - const request = renderWebhookPrompt("{{request}}", githubPullRequest, "id").prompt; - assert.isTrue(request.startsWith("POST /api/hooks/task-1?source=github\n")); - assert.include(request, "x-github-event: pull_request"); - assert.isTrue(request.endsWith(`${githubPullRequest.bodyText}${NOTE}`)); - }); - it("renders missing fields empty and reports them once", () => { const rendered = renderWebhookPrompt( "a={{body.nope}} b={{body.nope}} c={{headers.x-missing}} d={{unknown}}", @@ -115,22 +87,6 @@ describe("renderWebhookPrompt", () => { assert.deepEqual(rendered.missing, ["body.nope", "headers.x-missing", "unknown"]); }); - it("leaves empty values unframed and does not report them missing", () => { - const rendered = renderWebhookPrompt("{{body}}", { ...githubPullRequest, bodyText: "" }, "id"); - assert.equal(rendered.prompt, ""); - assert.deepEqual(rendered.missing, []); - }); - - it("trims placeholder whitespace before resolving and reporting missing fields", () => { - const rendered = renderWebhookPrompt( - "{{\tbody.action\n}}|{{ body.nope }}|{{\nbody.nope\t}}|{{ \t\n }}", - githubPullRequest, - "id", - ); - assert.equal(rendered.prompt, `opened|||${NOTE}`); - assert.deepEqual(rendered.missing, ["body.nope", ""]); - }); - it("preserves unclosed whitespace-heavy placeholders and resolves later valid ones", () => { const unfinished = `Notify {{${" ".repeat(1024)}body`; const rendered = renderWebhookPrompt( diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index b129704f0abe..063d41a7ec46 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -112,16 +112,17 @@ function formatWebhookRequest(request: WebhookRequest): string { ].join("\n"); } -export function renderWebhookPrompt( - template: string, +/** + * Resolves a placeholder expression (`body.action`, `headers.x-github-event`) + * against the request. Webhook filters share it, so both read the same value. + */ +export function webhookExpressionResolver( request: WebhookRequest, - frameId: string, -): RenderedWebhookPrompt { - const missing: string[] = []; +): (expression: string) => unknown { let body: { parsed: unknown } | undefined; const parsedBody = () => (body ??= { parsed: parseBody(request) }).parsed; - const resolve = (expression: string): unknown => { + return (expression) => { const [root, ...segments] = expression.split("."); switch (root) { case "request": @@ -143,6 +144,16 @@ export function renderWebhookPrompt( return undefined; } }; +} + +export function renderWebhookPrompt( + template: string, + request: WebhookRequest, + frameId: string, + raw = false, +): RenderedWebhookPrompt { + const missing: string[] = []; + const resolve = webhookExpressionResolver(request); const tag = `webhook-data-${frameId}`; let framed = false; @@ -150,12 +161,13 @@ export function renderWebhookPrompt( const expression = rawExpression.trim(); const value = resolve(expression); const text = value === undefined || value === null ? undefined : stringify(value); - if (text === undefined) { + if (text === undefined || (raw && text === "")) { if (!missing.includes(expression)) missing.push(expression); return ""; } // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; + if (raw) return text; framed = true; return `<${tag}>${text.replace(MENTION, "$&\u2060")}`; }); diff --git a/apps/server/src/scheduledTasks/webhookVerification.test.ts b/apps/server/src/scheduledTasks/webhookVerification.test.ts index b5c2b676084c..330128bfb1a0 100644 --- a/apps/server/src/scheduledTasks/webhookVerification.test.ts +++ b/apps/server/src/scheduledTasks/webhookVerification.test.ts @@ -12,11 +12,6 @@ const hmac = () => NodeCrypto.createHmac("sha256", secret).update(body); describe("verifyWebhookSignature", () => { const github = { header: "x-hub-signature-256", encoding: "hex", prefix: "sha256=" } as const; - it("accepts a GitHub-style hex signature with prefix", () => { - const headers = { "x-hub-signature-256": `sha256=${hmac().digest("hex")}` }; - assert.isTrue(verifyWebhookSignature({ signature: github, secret, headers, body })); - }); - it("rejects a tampered body, a wrong secret, a missing header and a missing prefix", () => { const valid = `sha256=${hmac().digest("hex")}`; const tampered = new TextEncoder().encode('{"action":"closed"}'); diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index b751b0b205c5..ffb8cd314d87 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -7,17 +7,14 @@ import { ThreadId, TurnItemId, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; -import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as SecretRequests from "./SecretRequests.ts"; @@ -211,24 +208,6 @@ it.effect("a save whose record failed can be saved again", () => ), ); -it.effect("a save whose record and cleanup both failed is finished by saving again", () => - withService( - ({ service }) => - Effect.gen(function* () { - yield* service - .answer({ threadId, turnItemId, answer: { type: "save", secret: "ghp_secret" } }) - .pipe(Effect.flip); - yield* service.answer({ - threadId, - turnItemId, - answer: { type: "save", secret: "ghp_secret" }, - }); - assert.isTrue(Option.isSome(yield* service.savedRef({ threadId, turnItemId }))); - }), - { failedRecords: 1, removeFails: true }, - ), -); - it.effect("a ref only works in the project it was entered for", () => withService(({ service }) => Effect.gen(function* () { @@ -343,48 +322,6 @@ it.effect("refuses an answer once the agent that asked has stopped", () => ), ); -it.effect("drops values nobody used once they expire, and keeps the rest", () => - Effect.gen(function* () { - const store = yield* ServerSecretStore.ServerSecretStore; - const encode = (savedAt: number) => - new TextEncoder().encode( - `{"projectId":"project-1","value":"ghp_secret","savedAt":${savedAt}}`, - ); - yield* TestClock.adjust("2 days"); - const now = yield* Clock.currentTimeMillis; - const stale = `secret-request-${"a".repeat(32)}`; - const fresh = `secret-request-${"b".repeat(32)}`; - yield* store.set(stale, encode(now - 25 * 60 * 60 * 1000)); - yield* store.set(fresh, encode(now)); - yield* store.set("unrelated", new Uint8Array([1])); - - yield* Effect.gen(function* () { - yield* SecretRequests.SecretRequests; - }).pipe( - Effect.provide( - SecretRequests.layer.pipe( - Layer.provide(Layer.mock(ThreadManagementService.ThreadManagementService)({})), - ), - ), - Effect.scoped, - ); - - assert.isTrue(Option.isNone(yield* store.get(stale))); - assert.isTrue(Option.isSome(yield* store.get(fresh))); - assert.isTrue(Option.isSome(yield* store.get("unrelated"))); - assert.isTrue(Option.isSome(yield* store.get("secret-request-salt"))); - }).pipe( - Effect.provide( - ServerSecretStore.layer.pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-requests-" }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), -); - it.effect("a used value that cannot be deleted is not handed out", () => withService( ({ service }) => diff --git a/apps/server/src/serverActivation.test.ts b/apps/server/src/serverActivation.test.ts deleted file mode 100644 index dc41b884ab36..000000000000 --- a/apps/server/src/serverActivation.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; - -import * as ServerActivation from "./serverActivation.ts"; - -it.effect("proves a root is parked before returning and releases it with one gate", () => - Effect.scoped( - Effect.gen(function* () { - const activation = yield* Deferred.make(); - const ran = yield* Deferred.make(); - - yield* ServerActivation.forkParked(Deferred.succeed(ran, undefined)).pipe( - Effect.provideService(ServerActivation.ServerActivation, Deferred.await(activation)), - ); - expect(yield* Deferred.isDone(ran)).toBe(false); - - yield* Deferred.succeed(activation, undefined); - yield* Deferred.await(ran); - expect(yield* Deferred.isDone(ran)).toBe(true); - }), - ), -); diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts deleted file mode 100644 index 28ba41125b57..000000000000 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { assert, it } from "@effect/vitest"; -import { assertTrue } from "@effect/vitest/utils"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; - -it.effect( - "publishes lifecycle events without subscribers and snapshots the latest welcome/ready", - () => - Effect.gen(function* () { - const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; - const environment = { - environmentId: EnvironmentId.make("environment-test"), - label: "Test environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }; - - const welcome = yield* lifecycleEvents - .publish({ - version: 1, - type: "welcome", - payload: { - environment, - cwd: "/tmp/project", - projectName: "project", - }, - }) - .pipe(Effect.timeoutOption("50 millis")); - assertTrue(Option.isSome(welcome)); - assert.equal(welcome.value.sequence, 1); - - const ready = yield* lifecycleEvents - .publish({ - version: 1, - type: "ready", - payload: { - at: "2026-01-01T00:00:00.000Z", - environment, - }, - }) - .pipe(Effect.timeoutOption("50 millis")); - assertTrue(Option.isSome(ready)); - assert.equal(ready.value.sequence, 2); - - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "running", - totalThreadCount: 12, - }, - }); - yield* lifecycleEvents.publish({ - version: 1, - type: "legacyThreadMigration", - payload: { - status: "complete", - totalThreadCount: 12, - }, - }); - - const snapshot = yield* lifecycleEvents.snapshot; - assert.equal(snapshot.sequence, 4); - assert.deepEqual(snapshot.events.map((event) => event.type).toSorted(), [ - "legacyThreadMigration", - "ready", - "welcome", - ]); - const migration = snapshot.events.find((event) => event.type === "legacyThreadMigration"); - assert.equal(migration?.payload.status, "complete"); - }).pipe(Effect.provide(ServerLifecycleEvents.layer)), -); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 70b4cd86b2cd..75c35a61b03a 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -5,7 +5,6 @@ import * as NodeUtil from "node:util"; import { ExternalLauncherBrowserSpawnError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -96,24 +95,6 @@ const layerConfig = (overrides: Partial) = }), ).pipe(Layer.provide(NodePath.layer)); -/** - * The export is asserted after the layer's scope closes, which is where the - * exporter flushes whatever the interval did not. - */ -const logThrough = (overrides: Partial) => - Effect.gen(function* () { - const requests: Array = []; - yield* Effect.log("server logger under test").pipe( - Effect.provide( - ServerLogger.layer.pipe( - Layer.provide(layerConfig(overrides)), - Layer.provide(layerCollector(requests)), - ), - ), - ); - return requests; - }); - /** * Runs `program` (one log line by default) inside a span so the tracer logger * has somewhere to attach an event, then reports both what the collector @@ -171,99 +152,6 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { - it.effect("exports log records to the configured logs endpoint", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }); - - assert.lengthOf(requests, 1); - const [request] = requests; - assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); - assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3code-server"); - assert.include(request?.body ?? "", "service.runtime"); - }), - ); - - it.effect("keeps its service name while OTEL resource attributes add dimensions", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }).pipe( - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - OTEL_SERVICE_NAME: "renamed", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", - }, - }), - ), - ), - ); - - assert.lengthOf(requests, 1); - const body = requests[0]?.body ?? ""; - assert.include(body, '"stringValue":"t3code-server"'); - assert.include(body, "deployment.environment.name"); - assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); - assert.notInclude(body, "renamed"); - }), - ); - - it.effect("stays off the network when no logs endpoint is configured", () => - Effect.gen(function* () { - const requests = yield* logThrough({}); - - assert.lengthOf(requests, 0); - }), - ); - - it.effect("sends the headers and wire format the log signal asked for", () => - Effect.gen(function* () { - const requests = yield* logThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - otlpLogsExport: { - ...DEFAULT_SIGNAL_EXPORT, - protocol: "http/protobuf", - headers: { "x-scope": "logs" }, - }, - }); - - assert.lengthOf(requests, 1); - assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); - assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); - }), - ); - - it.effect("attaches log messages to the active span when no logs endpoint is configured", () => - Effect.gen(function* () { - const { requests, spans } = yield* logInSpanThrough({}); - - assert.lengthOf(requests, 0); - assert.lengthOf(spans, 1); - assert.deepEqual( - spans[0]?.events.map(([name]) => name), - ["server logger under test"], - ); - }), - ); - - it.effect("stops duplicating messages onto the span once log records are exported", () => - Effect.gen(function* () { - const { requests, spans } = yield* logInSpanThrough({ - otlpLogsUrl: "https://collector.example.com/v1/logs", - }); - - assert.lengthOf(requests, 1); - assert.include(requests[0]?.body ?? "", "server logger under test"); - assert.lengthOf(spans, 1); - assert.lengthOf(spans[0]?.events ?? [], 0); - }), - ); - it.effect("prints a cause passed as a log field instead of collapsing it", () => Effect.gen(function* () { const output = yield* printThroughConsole({ @@ -277,6 +165,7 @@ describe("ServerLoggerLive", () => { }), ); + // regression: #16642 logs printed causes as [Object] it.effect("leaves getters uncalled while printing a cause field", () => Effect.gen(function* () { const output = yield* printThroughConsole({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 34ccf14a8379..c1fef2623ae8 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -2,7 +2,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { DEFAULT_SERVER_SETTINGS, - DEFAULT_MODEL, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -48,13 +47,6 @@ import * as ServerSettings from "./serverSettings.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -it("uses the canonical Codex model for auto-bootstrap", () => { - assert.deepEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect.each([ "disabled-codex", "disabled-default", @@ -250,33 +242,6 @@ it.effect.each([ ), ); -it.effect("starts without scanning or rebuilding projection history", () => - Effect.gen(function* () { - const calls = yield* Ref.make>([]); - const record = (label: string) => Ref.update(calls, (current) => [...current, label]); - - const result = yield* ServerRuntimeStartup.runOrderedV2StartupPhases({ - importLegacyShells: record("import"), - recover: record("recover").pipe(Effect.as({ closedRequests: 2 })), - recoverDelegatedTasks: record("delegated"), - startEffectWorker: record("worker"), - autoBootstrap: record("bootstrap").pipe(Effect.as({ projectId: "project-1" })), - }); - - assert.deepEqual(yield* Ref.get(calls), [ - "import", - "recover", - "delegated", - "worker", - "bootstrap", - ]); - assert.deepEqual(result, { - recovery: { closedRequests: 2 }, - bootstrap: { projectId: "project-1" }, - }); - }), -); - it.effect("interrupts the effect worker when awareness relay startup fails", () => Effect.scoped( Effect.gen(function* () { @@ -340,21 +305,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("resolveWelcomeBase derives cwd and project name from server config", () => - Effect.gen(function* () { - const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( - Effect.provideService(ServerConfig.ServerConfig, { - cwd: "/tmp/startup-project", - } as never), - ); - - assert.deepStrictEqual(welcome, { - cwd: "/tmp/startup-project", - projectName: "startup-project", - }); - }), -); - it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 0f5a1d206e83..592db0194c6d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -180,7 +180,7 @@ const recordStartupHeartbeat = Effect.gen(function* () { yield* analytics.recordOwners(yield* getServerOwners, "server"); }); -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -250,7 +250,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); -export const resolveWelcomeBase = Effect.gen(function* () { +const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project"; @@ -420,36 +420,36 @@ export const startEffectWorkerWithRelay = Effect.fn( ); }); -export const runOrderedV2StartupPhases = Effect.fn( - "serverRuntimeStartup.runOrderedV2StartupPhases", -)(function* < - Import, - Recovery, - Bootstrap, - ImportError, - RecoveryError, - DelegationError, - WorkerError, - BootstrapError, - ImportContext, - RecoveryContext, - DelegationContext, - WorkerContext, - BootstrapContext, ->(input: { - readonly importLegacyShells: Effect.Effect; - readonly recover: Effect.Effect; - readonly recoverDelegatedTasks: Effect.Effect; - readonly startEffectWorker: Effect.Effect; - readonly autoBootstrap: Effect.Effect; -}) { - yield* input.importLegacyShells; - const recovery = yield* input.recover; - yield* input.recoverDelegatedTasks; - yield* input.startEffectWorker; - const bootstrap = yield* input.autoBootstrap; - return { recovery, bootstrap } as const; -}); +const runOrderedV2StartupPhases = Effect.fn("serverRuntimeStartup.runOrderedV2StartupPhases")( + function* < + Import, + Recovery, + Bootstrap, + ImportError, + RecoveryError, + DelegationError, + WorkerError, + BootstrapError, + ImportContext, + RecoveryContext, + DelegationContext, + WorkerContext, + BootstrapContext, + >(input: { + readonly importLegacyShells: Effect.Effect; + readonly recover: Effect.Effect; + readonly recoverDelegatedTasks: Effect.Effect; + readonly startEffectWorker: Effect.Effect; + readonly autoBootstrap: Effect.Effect; + }) { + yield* input.importLegacyShells; + const recovery = yield* input.recover; + yield* input.recoverDelegatedTasks; + yield* input.startEffectWorker; + const bootstrap = yield* input.autoBootstrap; + return { recovery, bootstrap } as const; + }, +); const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: StartupOptions) { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 86b1cd093212..abafbc7e24f6 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -4,12 +4,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as References from "effect/References"; import * as Runtime from "effect/Runtime"; -import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; @@ -19,8 +16,6 @@ import * as ProcessRunner from "./processRunner.ts"; import * as ServerOwnership from "./serverOwnership.ts"; import * as ServerRuntimeState from "./serverRuntimeState.ts"; -const isServerRuntimeStateError = Schema.is(ServerRuntimeState.ServerRuntimeStateError); - const startBesideBootService = Effect.fn("test.startBesideBootService")(function* (input: { readonly platform: "darwin" | "linux"; readonly outputs: Readonly>; @@ -132,11 +127,6 @@ const startBesideBootService = Effect.fn("test.startBesideBootService")(function return { result, t3Home }; }); -interface CapturedLog { - readonly message: unknown; - readonly annotations: Readonly>; -} - describe("server ownership", () => { // An empty home, so no boot service unit installed on this machine is read. const layerOwnership = Layer.mergeAll( @@ -206,8 +196,8 @@ describe("server ownership", () => { const state = yield* makeState; yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, - // @effect-diagnostics-next-line globalDateInEffect:off - Legacy identity uses the real process start time, not TestClock. - state: { ...state, startedAt: new Date().toISOString() }, + // Any record time after this process started proves the PID was not reused. + state: { ...state, startedAt: "2100-01-01T00:00:00.000Z" }, }); const refused = yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)).pipe( Effect.flip, @@ -407,187 +397,3 @@ describe("server ownership", () => { ); }); }); - -describe("serverRuntimeState", () => { - it.effect("persists and reads the runtime state", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "runtime", "server.json"); - const state: ServerRuntimeState.PersistedServerRuntimeState = { - version: 1, - pid: 123, - host: "127.0.0.1", - port: 4_971, - origin: "http://127.0.0.1:4971", - devUrl: "http://localhost:5733/", - startedAt: "2026-06-20T00:00:00.000Z", - }; - - yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.deepEqual(Option.getOrThrow(restored), state); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("records the dev web URL when the server fronts a dev server", () => - Effect.gen(function* () { - const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: new URL("http://localhost:5733") }, - port: 13_773, - }); - - assert.equal(state.devUrl, "http://localhost:5733/"); - assert.equal(state.origin, "http://127.0.0.1:13773"); - - const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - assert.isFalse("devUrl" in withoutDev); - }), - ); - - it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () => - Effect.gen(function* () { - const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - serviceManaged: true, - }); - const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, - port: 13_773, - }); - - assert.isTrue(managed.serviceManaged); - assert.isFalse("serviceManaged" in manual); - }), - ); - - it.effect("treats a missing runtime state file as absent", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState( - path.join(root, "missing.json"), - ); - - assert.isTrue(Option.isNone(restored)); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("preserves malformed state decode failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.writeFileString(statePath, "{not json"); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to decode server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "decode"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to decode server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "SchemaError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - - it.effect("preserves runtime state read failures", () => { - const logs: CapturedLog[] = []; - const logger = Logger.make(({ fiber, message }) => { - logs.push({ - message, - annotations: fiber.getRef(References.CurrentLogAnnotations), - }); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const statePath = path.join(root, "server.json"); - yield* fileSystem.makeDirectory(statePath); - - const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); - - assert.isTrue(Option.isNone(restored)); - assert.equal(logs[0]?.message, `Failed to read server runtime state at ${statePath}.`); - const error = logs[0]?.annotations.cause; - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "read"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to read server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe( - Effect.provide( - Layer.merge(NodeServices.layer, Logger.layer([logger], { mergeWithExisting: false })), - ), - ); - }); - - it.effect("preserves runtime state persistence failures", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-runtime-state-test-", - }); - const blockedDirectory = path.join(root, "not-a-directory"); - const statePath = path.join(blockedDirectory, "server.json"); - yield* fileSystem.writeFileString(blockedDirectory, "blocked"); - - const error = yield* ServerRuntimeState.persistServerRuntimeState({ - path: statePath, - state: { - version: 1, - pid: 123, - port: 4_971, - origin: "http://127.0.0.1:4971", - startedAt: "2026-06-20T00:00:00.000Z", - }, - }).pipe(Effect.flip); - - assert.isTrue(isServerRuntimeStateError(error)); - if (isServerRuntimeStateError(error)) { - assert.equal(error.operation, "persist"); - assert.equal(error.statePath, statePath); - assert.equal(error.message, `Failed to persist server runtime state at ${statePath}.`); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - } - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 177dfec5a025..6929cf4fb6f6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -8,14 +8,12 @@ import { ProviderInstanceId, resolveProviderInstanceEnabled, ServerSettings, - ServerSettingsPatch, UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -24,7 +22,6 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts"; @@ -34,7 +31,6 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import * as ServerSettingsModule from "./serverSettings.ts"; import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; -const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); @@ -270,154 +266,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("reloads when the destination of a symlinked settings file changes", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const linkedSettingsPath = path.join(dotfiles, "settings.json"); - yield* fs.writeFileString(linkedSettingsPath, `{ "responseStreamingMode": "turn" }`); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(linkedSettingsPath, config.settingsPath); - yield* service.start; - const changes = yield* service.subscribeChanges; - - yield* writeFileStringAtomically({ - filePath: linkedSettingsPath, - contents: `{ "responseStreamingMode": "paragraph" }`, - }); - - const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("follows a settings link that is repointed to another directory", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const firstSettingsPath = path.join(dotfiles, "first", "settings.json"); - const secondSettingsPath = path.join(dotfiles, "second", "settings.json"); - yield* fs.makeDirectory(path.dirname(firstSettingsPath), { recursive: true }); - yield* fs.makeDirectory(path.dirname(secondSettingsPath), { recursive: true }); - yield* fs.writeFileString(firstSettingsPath, `{ "responseStreamingMode": "turn" }`); - yield* fs.writeFileString(secondSettingsPath, `{ "responseStreamingMode": "paragraph" }`); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(firstSettingsPath, config.settingsPath); - yield* service.start; - - const repointChanges = yield* service.subscribeChanges; - yield* fs.remove(config.settingsPath); - yield* fs.symlink(secondSettingsPath, config.settingsPath); - const repointed = yield* repointChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(repointed)?.responseStreamingMode, "paragraph"); - - const editChanges = yield* service.subscribeChanges; - yield* writeFileStringAtomically({ - filePath: secondSettingsPath, - contents: `{ "responseStreamingMode": "turn" }`, - }); - const edited = yield* editChanges.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(edited)?.responseStreamingMode, "turn"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("reloads when a dangling settings link gets its destination", () => - Effect.scoped( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const service = yield* ServerSettingsModule.ServerSettingsService; - const dotfiles = yield* fs.makeTempDirectoryScoped({ prefix: "t3-settings-dotfiles-" }); - const linkedSettingsPath = path.join(dotfiles, "not-yet", "settings.json"); - yield* fs.remove(config.settingsPath, { force: true }); - yield* fs.symlink(linkedSettingsPath, config.settingsPath); - yield* service.start; - const changes = yield* service.subscribeChanges; - - yield* writeFileStringAtomically({ - filePath: linkedSettingsPath, - contents: `{ "responseStreamingMode": "paragraph" }`, - }); - - const change = yield* changes.pipe(Stream.runHead, Effect.timeout("2 seconds")); - assert.equal(Option.getOrUndefined(change)?.responseStreamingMode, "paragraph"); - }), - ).pipe(TestClock.withLive, Effect.provide(layerServerSettings())), - ); - - it.effect("preserves context when reading a provider environment secret fails", () => { - const platformCause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: "provider environment secret", - description: "Secret backend unavailable.", - }); - const cause = new ServerSecretStore.SecretStoreReadError({ - resource: "provider environment secret", - cause: platformCause, - }); - const layerConfig = Layer.fresh( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-server-settings-secret-failure-test-", - }), - ); - const layerSettings = ServerSettingsModule.layer.pipe( - Layer.provide(layerFailingSecretStore(cause)), - Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), - Layer.provideMerge(layerConfig), - ); - - return Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - '{"providerInstances":{"codex_personal":{"driver":"codex","environment":[{"name":"OPENROUTER_API_KEY","value":"","sensitive":true,"valueRedacted":true}],"config":{}}}}', - ); - - const error = yield* Effect.flip(serverSettings.getSettings); - - assert.deepInclude(error, { - _tag: "ServerSettingsError", - operation: "read-secret", - providerInstanceId: "codex_personal", - environmentVariable: "OPENROUTER_API_KEY", - }); - assert.strictEqual(error.cause, cause); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(layerSettings)); - }); - - it.effect("identifies provider history query failures", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const sql = yield* SqlClient.SqlClient; - yield* sql`DROP TABLE projection_thread_sessions`; - - const error = yield* Effect.flip(serverSettings.getSettings); - - assert.deepInclude(error, { - _tag: "ServerSettingsError", - operation: "read-provider-history", - settingsPath: serverConfig.settingsPath, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("retries a failed settings read instead of keeping the failure", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -440,23 +288,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("decodes nested settings patches", () => - Effect.gen(function* () { - assert.deepEqual( - yield* decodeSettingsPatch({ - textGenerationModelSelection: { - options: [{ id: "fastMode", value: false }], - }, - }), - { - textGenerationModelSelection: { - options: [{ id: "fastMode", value: false }], - }, - }, - ); - }), - ); - it.effect( "decodes legacy object-shaped textGenerationModelSelection.options from settings.json", () => @@ -773,33 +604,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); - it.effect("persists and broadcasts thread settlement settings", () => - Effect.scoped( - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const changes = yield* serverSettings.subscribeChanges; - - const next = yield* serverSettings.updateSettings({ - sidebarAutoSettleAfterDays: null, - sidebarAutoSettleOnMerge: false, - }); - const change = Option.getOrUndefined(yield* Stream.runHead(changes)); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - - const persisted = JSON.parse(raw) as Record; - - assert.strictEqual(next.sidebarAutoSettleAfterDays, null); - assert.isFalse(next.sidebarAutoSettleOnMerge); - assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); - assert.isFalse(change?.sidebarAutoSettleOnMerge); - assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); - assert.isFalse(persisted.sidebarAutoSettleOnMerge); - }), - ).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -835,31 +639,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves custom provider instance text generation selections", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [ProviderInstanceId.make("claude_openrouter")]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("claude_openrouter"), - model: "openai/gpt-5.5", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId: ProviderInstanceId.make("claude_openrouter"), - model: "openai/gpt-5.5", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "keeps a custom instance's selection when the driver's default instance is disabled", () => @@ -1013,32 +792,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves enabled text generation selections for non-built-in drivers", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const instanceId = ProviderInstanceId.make("openrouter_text"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("openrouter"), - enabled: true, - config: { customModels: ["openai/gpt-5.5"] }, - }, - }, - textGenerationModelSelection: { - instanceId, - model: "openai/gpt-5.5", - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId, - model: "openai/gpt-5.5", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "preserves the source control writer selection when its provider instance is disabled", () => @@ -1105,76 +858,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("drops stale text generation options when resetting model selection", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - options: createModelSelection( - ProviderInstanceId.make("codex"), - DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: true }, - ], - ).options!, - }, - }); - - const next = yield* serverSettings.updateSettings({ - textGenerationModelSelection: { - instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }, - }); - - assert.deepEqual(next.textGenerationModelSelection, { - instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, - model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("replaces provider instance maps when clearing optional fields", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const codexId = ProviderInstanceId.make("codex"); - - yield* serverSettings.updateSettings({ - providerInstances: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - accentColor: "#7c3aed", - enabled: true, - config: { homePath: "~/.codex" }, - }, - }, - }); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [codexId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - enabled: true, - config: { homePath: "~/.codex" }, - }, - }, - }); - - assert.deepEqual(next.providerInstances[codexId], { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Work", - enabled: true, - config: { homePath: "~/.codex" }, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("moves customized legacy provider settings into default instances on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1322,20 +1005,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("runs provider history only for files that still carry the legacy map", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); - yield* recordProviderUsage("grok"); - - const settings = yield* serverSettings.getSettings; - - assert.deepEqual(settings.providerInstances, {}); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("migrates legacy providers from an invalid file without rewriting it", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1357,22 +1026,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips a disabled provider instance when picking the text generation fallback", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString( - serverConfig.settingsPath, - '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', - ); - - const settings = yield* serverSettings.getSettings; - - assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1431,85 +1084,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("folds in-config enabled flags arriving through updates", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const grokId = ProviderInstanceId.make("grok"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [grokId]: { - driver: ProviderDriverKind.make("grok"), - enabled: true, - config: { enabled: false, binaryPath: "/opt/grok" }, - }, - }, - }); - - assert.deepEqual(next.providerInstances[grokId], { - driver: ProviderDriverKind.make("grok"), - enabled: false, - config: { binaryPath: "/opt/grok" }, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("trims observability settings when updates are applied", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: " ~/Development ", - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - otlpLogsUrl: " http://localhost:4318/v1/logs ", - }, - }); - - assert.equal(next.addProjectBaseDirectory, "~/Development"); - assert.deepEqual(next.observability, { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - otlpLogsUrl: "http://localhost:4318/v1/logs", - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("writes only non-default settings to disk", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "~/Development", - observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }, - automaticGitFetchInterval: Duration.seconds(10), - }); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.deepEqual(JSON.parse(raw), { - addProjectBaseDirectory: "~/Development", - observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }, - backgroundActivity: { - schemaVersion: 1, - profile: "custom", - baseProfile: "balanced", - overrides: { - automaticGitFetchInterval: 10_000, - }, - }, - automaticGitFetchInterval: 10_000, - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("keeps the inline value on disk when secret migration fails", () => { const cause = new ServerSecretStore.SecretStorePersistError({ resource: "provider environment secret", @@ -1847,69 +1421,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("stores sensitive provider instance environment values outside settings.json", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const instanceId = ProviderInstanceId.make("codex_personal"); - - const next = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - environment: [ - { name: "OPENROUTER_API_KEY", value: "sk-or-secret", sensitive: true }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ], - config: {}, - }, - }, - }); - - assert.deepEqual(next.providerInstances[instanceId]?.environment, [ - { - name: "OPENROUTER_API_KEY", - value: "sk-or-secret", - sensitive: true, - valueRedacted: true, - }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ]); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, "sk-or-secret"); - assert.deepEqual(JSON.parse(raw).providerInstances.codex_personal.environment, [ - { - name: "OPENROUTER_API_KEY", - value: "", - sensitive: true, - valueRedacted: true, - }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ]); - - const roundTripped = yield* serverSettings.updateSettings({ - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("codex"), - displayName: "Codex Personal", - environment: [ - { name: "OPENROUTER_API_KEY", value: "", sensitive: true, valueRedacted: true }, - { name: "ANTHROPIC_BASE_URL", value: "https://openrouter.ai/api", sensitive: false }, - ], - config: {}, - }, - }, - }); - - assert.equal( - roundTripped.providerInstances[instanceId]?.environment?.[0]?.value, - "sk-or-secret", - ); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect( "keeps source control host secrets in the secret store and tells clients only that one is set", () => diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index faba5b270714..70550f237927 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -14,7 +14,6 @@ import { Launcher, main, readServiceEnvironment, - PREPARED_TIMEOUT_MS, type BackupSpaceProbe, readServiceState, writeServiceState, @@ -26,7 +25,6 @@ import { SERVICE_ENV_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, - SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; const spawnedChildren = vi.hoisted((): Array => []); @@ -175,22 +173,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("durably replaces and strictly reads one state document", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const state = { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "0.0.31", - } as const; - - yield* Effect.promise(() => writeServiceState(statePath, state)); - assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), state); - }), - ); - it.effect("a fresh launcher clears a restart deferred by t3 update", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -271,34 +253,6 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("serializes shutdown with launcher recovery", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", "1.0.0"), - "setInterval(() => {}, 1_000);\n", - ); - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), - ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - const running = launcher.run(); - const stopping = launcher.stop("SIGTERM"); - assert.isTrue(yield* fs.exists(path.join(root, "runtime", SERVICE_STOP_MARKER_FILE))); - yield* Effect.promise(() => stopping); - yield* Effect.promise(() => running); - }), - ); - it.effect("commits only after the trial reports prepared", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -428,82 +382,6 @@ if (context.update?.status === "pending") { }), ); - it.effect.each([ - { prepares: true, status: "committed", activeVersion: "1.1.0" }, - { prepares: false, status: "rolled-back", activeVersion: "1.0.0" }, - ] as const)( - "gives a trial the full prepared window (prepares: $prepares)", - ({ prepares, status, activeVersion }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-slow-" }); - const statePath = path.join(root, "runtime", "service-state.json"); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "before trial"); - const childSource = ` -const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); -if (context.update?.status !== "pending") process.exit(0); -process.on("message", (message) => { - if (message.type === "test-prepare") process.send({ type: "prepared", updateId: context.update.id }); - if (message.type === "committed") process.exit(0); -}); -setInterval(() => {}, 1_000); -`; - for (const version of ["1.0.0", "1.1.0"]) { - yield* writeFakeRuntime( - fs, - path, - path.join(root, "runtime", "versions", version), - childSource, - ); - } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - update: { - id: "update-1", - fromVersion: "1.0.0", - targetVersion: "1.1.0", - dbPath: databasePath, - status: "pending", - }, - }), - ); - - // Effect schedules on setImmediate, so faking only timeouts leaves it running. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); - const launcher = new Launcher( - root, - yield* Effect.promise(() => readServiceState(statePath)), - ); - const running = launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), - ); - yield* Effect.promise(() => - vi.waitFor(() => assert.equal(vi.getTimerCount(), 1), { timeout: 10_000 }), - ); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(121_000)); - if (prepares) spawnedChildren.at(-1)!.send({ type: "test-prepare" }); - else yield* Effect.promise(() => vi.advanceTimersByTimeAsync(PREPARED_TIMEOUT_MS)); - yield* Effect.promise(() => running); - - const state = yield* Effect.promise(() => readServiceState(statePath)); - assert.equal(state.activeVersion, activeVersion); - assert.equal(state.update?.status, status); - if (!prepares) { - assert.equal( - state.update?.status === "rolled-back" ? state.update.reason : undefined, - "prepared-timeout", - ); - } - }), - ); - it.effect.each(["file", "symlink", "dangling symlink"] as const)( "restores the database when a migrating trial exits, with the database a %s", (kind) => @@ -764,41 +642,4 @@ setInterval(() => {}, 1_000); assert.include(blocker ?? "", "The server was not stopped"); }), ); - - it.effect("fails the backup check when the database cannot be inspected", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-blocker-" }); - const error = yield* Effect.promise(() => - databaseBackupBlocker(root, path.join(root, "missing.sqlite"), { - readFreeBytes: () => Promise.resolve(0), - }).then( - () => undefined, - (cause: unknown) => cause, - ), - ); - assert.equal((error as NodeJS.ErrnoException | undefined)?.code, "ENOENT"); - }), - ); - - it.effect("recognizes a clone into the backup directory on this filesystem", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-clone-" }); - const databasePath = path.join(root, "userdata", "state.sqlite"); - yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); - yield* fs.writeFileString(databasePath, "database"); - const blocker = yield* Effect.promise(() => - databaseBackupBlocker(root, databasePath, { readFreeBytes: () => Promise.resolve(0) }), - ); - assert.include( - blocker ?? "", - // oxlint-disable-next-line t3code/no-global-process-runtime -- mirrors the standalone launcher's own platform check. - process.platform === "darwin" ? "1.0 GB needed, 0.0 GB free" : "GB needed, 0.0 GB free", - ); - assert.deepEqual(yield* fs.readDirectory(path.join(root, "runtime", "db-backup")), []); - }), - ); }); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e57c4b149059..9926159be285 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -37,7 +37,7 @@ const HANDOFF_DELAY_MS = 2_000; * Migrations count toward it and can scale with the database: tens of GB on a cold page cache * take minutes. A missed window rolls back the database, so the next attempt pays the same cost. */ -export const PREPARED_TIMEOUT_MS = 30 * 60_000; +const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; // Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone // launcher stays independent of the Effect-backed contracts package. diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5a2c1eaceee0..38a5f112c382 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -4,15 +4,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ConfigProvider from "effect/ConfigProvider"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -254,68 +251,6 @@ it.effect("lists pull requests with Bitbucket state and source branch query para }).pipe(Effect.provide(layer)); }); -it.effect("lists closed pull requests with both closed Bitbucket states", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/closed", - state: "closed", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/closed" AND (state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - -it.effect("expands all-state pull request listing instead of relying on Bitbucket defaults", () => { - const { execute, layer } = makeLayer({ - response: () => - Response.json({ - values: [], - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.listPullRequests({ - cwd: "/repo", - headSelector: "feature/all", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(execute.mock.calls[0]?.[0].urlParams.params, [ - ["pagelen", "10"], - ["sort", "-updated_on"], - [ - "q", - 'source.branch.name = "feature/all" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")', - ], - ["state", "OPEN"], - ["state", "MERGED"], - ["state", "DECLINED"], - ["state", "SUPERSEDED"], - ]); - }).pipe(Effect.provide(layer)); -}); - it.effect("reads repository clone URLs and default branch", () => { const { layer } = makeLayer({ response: (request) => @@ -428,27 +363,6 @@ it.effect("prefers an explicit repository and uses context when the repository i }).pipe(Effect.provide(layer)); }); -it.effect("discovers the repository from cwd remotes when the repository is omitted", () => { - const { execute, resolve, listRemotes, layer } = makeLayer({ - response: (request) => - Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(branch, "main"); - assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]); - assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]); - assert.isTrue( - execute.mock.calls.every(([request]) => - request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"), - ), - ); - }).pipe(Effect.provide(layer)); -}); - it.effect( "prefers the Bitbucket branching model development branch as the default PR target", () => { @@ -510,57 +424,6 @@ it.effect( }, ); -it.effect( - "falls back to the repository main branch when the Bitbucket branching model is unavailable", - () => { - const { layer } = makeLayer({ - response: (request) => - request.url.endsWith("/branching-model") - ? Response.json({ error: { message: "Not found" } }, { status: 404 }) - : Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const defaultBranch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("creates repositories through the Bitbucket REST API", () => { - const { execute, layer } = makeLayer({ - response: () => Response.json(repositoryJson), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const cloneUrls = yield* bitbucket.createRepository({ - cwd: "/repo", - repository: "pingdotgg/t3code", - visibility: "private", - }); - - assert.deepStrictEqual(cloneUrls, { - nameWithOwner: "pingdotgg/t3code", - url: "https://bitbucket.org/pingdotgg/t3code.git", - sshUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }); - - const request = execute.mock.calls[0]?.[0]; - assert.strictEqual(request?.url, "https://api.test.local/2.0/repositories/pingdotgg/t3code"); - assert.strictEqual(request?.method, "POST"); - assert.ok(request); - const rawBody = (request.body as { readonly body?: Uint8Array }).body; - assert.ok(rawBody); - assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(rawBody)), { - scm: "git", - is_private: true, - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("creates pull requests using the official REST payload shape", () => { const { execute, layer } = makeLayer({ response: () => Response.json(bitbucketPullRequest), @@ -603,24 +466,6 @@ it.effect("creates pull requests using the official REST payload shape", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); -it.effect("reports auth status through the Bitbucket REST /user endpoint", () => { - const { layer } = makeLayer({ - response: () => Response.json({ username: "bitbucket-user" }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const auth = yield* bitbucket.probeAuth; - - assert.deepStrictEqual(auth, { - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }); - }).pipe(Effect.provide(layer)); -}); - it.effect("prefers credentials saved in settings over the environment, without a restart", () => { const { execute, layer } = makeLayer({ response: () => Response.json({ username: "bitbucket-user" }), @@ -703,42 +548,6 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm }).pipe(Effect.provide(layer)); }); -it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { - const transportCause = new Error("socket reset by peer"); - let requestFailure: HttpClientError.HttpClientError | undefined; - const { layer } = makeLayer({ - response: () => Response.json({}), - requestFailure: (request) => { - requestFailure = new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause: transportCause, - }), - }); - return requestFailure; - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.getPullRequest({ - cwd: "/repo", - reference: "42", - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", - ); - assert.strictEqual(error.cause, requestFailure); - assert.strictEqual(requestFailure?.cause, transportCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { const responseBody = '{"error":{"message":"credential=secret-value"}}'; const { layer } = makeLayer({ @@ -764,175 +573,6 @@ it.effect("keeps Bitbucket response bodies out of checkout diagnostics", () => { }).pipe(Effect.provide(layer)); }); -it.effect("keeps a 429 Retry-After time on the response error", () => { - const { layer } = makeLayer({ - response: () => new Response("busy", { status: 429, headers: { "Retry-After": "120" } }), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Bitbucket response body read failures as their immediate cause", () => { - const cause = new Error("response stream failed"); - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(cause), - }), - { status: 502 }, - ), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .getPullRequest({ cwd: "/repo", reference: "42" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.operation, "getPullRequest"); - assert.strictEqual(error.status, 502); - assert.instanceOf(error.cause, HttpClientError.HttpClientError); - assert.strictEqual(error.cause.cause, cause); - assert.strictEqual( - error.message, - "Bitbucket API failed in getPullRequest: Bitbucket returned HTTP 502.", - ); - }).pipe(Effect.provide(layer)); -}); - -it.effect("keeps the 429 retry time when the response body cannot be read", () => { - const { layer } = makeLayer({ - response: () => - new Response( - new ReadableStream({ - start: (controller) => controller.error(new Error("response stream failed")), - }), - { status: 429, headers: { "Retry-After": "120" } }, - ), - }); - - return Effect.gen(function* () { - yield* TestClock.setTime(1_000); - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* bitbucket - .request({ method: "GET", url: "/repositories/acme/web" }) - .pipe(Effect.flip); - - assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError); - assert.strictEqual(error.status, 429); - assert.strictEqual(error.retryAt, 121_000); - }).pipe(Effect.provide(layer)); -}); - -it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { - const { git, layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - yield* bitbucket.checkoutPullRequest({ - cwd: "/repo", - context: { - provider: { - kind: SourceControlProviderKind.make("bitbucket"), - name: "Bitbucket", - baseUrl: "https://bitbucket.org", - }, - remoteName: "origin", - remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git", - }, - reference: "42", - force: true, - }); - - assert.strictEqual(git.ensureRemote.mock.calls.length, 0); - assert.deepStrictEqual(git.fetchRemoteBranch.mock.calls[0]?.[0], { - cwd: "/repo", - remoteName: "origin", - remoteBranch: "feature/source-control", - localBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.setBranchUpstream.mock.calls[0]?.[0], { - cwd: "/repo", - branch: "feature/source-control", - remoteName: "origin", - remoteBranch: "feature/source-control", - }); - assert.deepStrictEqual(git.switchRef.mock.calls[0]?.[0], { - cwd: "/repo", - refName: "feature/source-control", - }); - }).pipe(Effect.provide(layer)); -}); - -it.effect("preserves Git checkout failures without deriving the domain message from them", () => { - const gitCause = new GitCommandError({ - operation: "fetchRemoteBranch", - command: "git fetch origin feature/source-control", - cwd: "/repo", - detail: "remote rejected the request", - }); - const { layer } = makeLayer({ - response: () => - Response.json({ - ...bitbucketPullRequest, - source: { - branch: { name: "feature/source-control" }, - repository: { - full_name: "pingdotgg/t3code", - workspace: { slug: "pingdotgg" }, - }, - }, - }), - git: { - fetchRemoteBranch: () => Effect.fail(gitCause), - }, - }); - - return Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - const error = yield* Effect.flip( - bitbucket.checkoutPullRequest({ - cwd: "/repo", - reference: "42", - force: true, - }), - ); - - assert.instanceOf(error, BitbucketApi.BitbucketCheckoutError); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "42"); - assert.strictEqual( - error.message, - "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", - ); - assert.strictEqual(error.cause, gitCause); - }).pipe(Effect.provide(layer)); -}); - it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { @@ -1096,79 +736,3 @@ it.effect("cuts a response short rather than reading an unbounded diff into memo ), ), ); - -const stalledResponseBody = (status: number): Response => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("{")); - }, - }), - { status, headers: { "content-type": "application/json" } }, - ); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket JSON body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect.each([200, 503])( - "times out a stalled Bitbucket raw body after HTTP %s headers", - (status) => { - const { execute, layer } = makeLayer({ response: () => stalledResponseBody(status) }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); - }, -); - -it.effect("times out before JSON headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.getPullRequest({ cwd: "/repo", reference: "42" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "getPullRequest"); - }).pipe(Effect.provide(layer)); -}); - -it.effect("times out before raw response headers arrive", () => { - const { execute, layer } = makeLayer({ response: () => Response.json({}), stall: true }); - return Effect.gen(function* () { - const api = yield* BitbucketApi.BitbucketApi; - const fiber = yield* Effect.forkChild( - Effect.flip(api.request({ method: "GET", url: "/user" })), - ); - while (execute.mock.calls.length === 0) yield* Effect.yieldNow; - yield* TestClock.adjust("31 seconds"); - const error = yield* Fiber.join(fiber); - assert.instanceOf(error, BitbucketApi.BitbucketRequestError); - assert.strictEqual(error.operation, "request"); - }).pipe(Effect.provide(layer)); -}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index eefa6534cbed..aad475ab54af 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -2,14 +2,13 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import type * as Context from "effect/Context"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient } from "effect/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -30,7 +29,6 @@ import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; -import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -98,138 +96,6 @@ const processOutput = ( }); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - -it.effect("reports implemented tools separately from locally available executables", () => { - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.command === "git") { - return Effect.succeed(processOutput("git version 2.51.0\n")); - } - if (input.command === "gh" && input.args[0] === "--version") { - return Effect.succeed(processOutput("gh version 2.83.0\n")); - } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { - return Effect.succeed( - processOutput( - encodeJson({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "juliusmarminge", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const layerTest = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - layerSourceControlProviderRegistryTest({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", - ), - }), - }, - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; - const result = yield* discovery.discover; - - assert.deepStrictEqual( - result.versionControlSystems.map((item) => ({ - kind: item.kind, - implemented: item.implemented, - status: item.status, - })), - [ - { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, - ], - ); - assert.deepStrictEqual( - result.sourceControlProviders.map((item) => ({ - kind: item.kind, - status: item.status, - auth: item.auth.status, - account: item.auth.account, - })), - [ - { - kind: SourceControlProviderKind.make("github"), - status: "available", - auth: "authenticated", - account: Option.some("juliusmarminge"), - }, - { - kind: SourceControlProviderKind.make("gitlab"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("azure-devops"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("bitbucket"), - status: "available", - auth: "unauthenticated", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("forgejo"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - { - kind: SourceControlProviderKind.make("gitcafe"), - status: "missing", - auth: "unknown", - account: Option.none(), - }, - ], - ); - const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); - assert.ok(bitbucket); - assert.strictEqual(bitbucket.executable, undefined); - }).pipe(Effect.provide(layerTest)); -}); it.effect("probes provider authentication without exposing token details", () => { const processMock = { diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index dc5a6505170b..80738fcf38a2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -18,7 +18,6 @@ import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, - VcsRepositoryDetectionError, } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; @@ -167,97 +166,6 @@ function makeRegistry(input: { ); } -it.effect("routes GitHub remotes to the GitHub provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("routes directly by provider kind for remote-first workflows", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [], - }); - - const provider = yield* registry.get(SourceControlProviderKind.make("github")); - - assert.strictEqual(provider.kind, "github"); - }), -); - -it.effect("includes the request cwd when an unregistered provider is used", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ remotes: [] }); - const provider = yield* registry.get(SourceControlProviderKind.make("unknown")); - - const error = yield* provider - .getChangeRequest({ cwd: "/repo", reference: "#42" }) - .pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "getChangeRequest"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.reference, "#42"); - }), -); - -it.effect("lists no change requests for a repository on an unrecognized host", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://git.example.internal/team/repo.git" }], - }); - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "unknown"); - assert.deepStrictEqual( - yield* provider.listChangeRequests({ cwd: "/repo", headSelector: "master", state: "all" }), - [], - ); - }), -); - -it.effect("retains VCS detection failures with structured cwd context", () => - Effect.gen(function* () { - const cause = new VcsRepositoryDetectionError({ - operation: "resolve", - cwd: "/repo", - detail: "raw VCS detection failure", - cause: new Error("raw nested failure"), - }); - const registry = yield* makeRegistry({ - remotes: [], - resolve: () => Effect.fail(cause), - }); - - const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); - - assert.strictEqual(error.provider, "unknown"); - assert.strictEqual(error.operation, "detectProvider"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.detail, "Failed to detect source control provider."); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.message), false); - }), -); - -it.effect("routes GitLab remotes to the GitLab provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - }), -); - it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -340,30 +248,6 @@ it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports }), ); -it.effect("routes Bitbucket remotes to the Bitbucket provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@bitbucket.org:pingdotgg/t3code.git" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "bitbucket"); - }), -); - -it.effect("routes Azure DevOps remotes to the Azure DevOps provider", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://dev.azure.com/acme/project/_git/repo" }], - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "azure-devops"); - }), -); - it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index fca680111c83..3620555b30d1 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -7,21 +7,16 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; -const isSourceControlProviderError = Schema.is(SourceControlProviderError); - const CLONE_URLS = { nameWithOwner: "octocat/t3code", url: "https://github.com/octocat/t3code", @@ -102,174 +97,6 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } -it.effect("looks up repositories through the requested provider without search", () => { - const calls: Array<{ cwd: string; repository: string }> = []; - const provider = makeProvider({ - getRepositoryCloneUrls: (input) => - Effect.sync(() => { - calls.push({ cwd: input.cwd, repository: input.repository }); - return CLONE_URLS; - }), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.lookupRepository({ - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - cwd: "/workspace", - }); - - assert.deepStrictEqual(result, { - provider: SourceControlProviderKind.make("github"), - ...CLONE_URLS, - }); - assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect("preserves provider failures without deriving the repository message from them", () => { - const providerCause = new SourceControlProviderError({ - provider: SourceControlProviderKind.make("github"), - operation: "getRepositoryCloneUrls", - cwd: "/workspace", - repository: "octocat/t3code", - detail: "credential token abc123 was rejected", - }); - const provider = makeProvider({ - getRepositoryCloneUrls: () => Effect.fail(providerCause), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - cwd: "/workspace", - }), - ); - - assert.strictEqual(error.provider, "github"); - assert.strictEqual(error.operation, "lookupRepository"); - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.strictEqual( - error.message, - "Source control repository operation lookupRepository failed for github: The source control operation could not be completed.", - ); - assert.strictEqual(error.cause, providerCause); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect( - "reports the safe Bitbucket locator detail through repository lookup and publishing", - () => - Effect.gen(function* () { - const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({ - repository: "credential-token-abc123", - }); - const provider = yield* BitbucketSourceControlProvider.make.pipe( - Effect.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - getRepositoryCloneUrls: () => Effect.fail(locatorCause), - createRepository: () => Effect.fail(locatorCause), - }), - ), - ); - - yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const input = { - provider: SourceControlProviderKind.make("bitbucket"), - repository: locatorCause.repository, - cwd: "/workspace", - }; - for (const operation of [ - service.lookupRepository(input).pipe(Effect.asVoid), - service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid), - ]) { - const error = yield* Effect.flip(operation); - - assert.strictEqual(error.provider, "bitbucket"); - assert.strictEqual( - error.detail, - "Bitbucket repositories must be specified as workspace/repository.", - ); - assert.notInclude(error.message, locatorCause.repository); - assert.instanceOf(error.cause, SourceControlProviderError); - assert.strictEqual( - isSourceControlProviderError(error.cause) ? error.cause.cause : null, - locatorCause, - ); - } - }).pipe(Effect.provide(layer({ provider }))); - }), -); - -it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => { - const provider = makeProvider({ - kind: SourceControlProviderKind.make("bitbucket"), - getRepositoryCloneUrls: () => - Effect.fail( - new SourceControlProviderError({ - provider: SourceControlProviderKind.make("bitbucket"), - operation: "getRepositoryCloneUrls", - cwd: "/workspace", - detail: "credential token abc123 was rejected", - cause: { - _tag: "BitbucketRepositoryLocatorError", - repository: "t3code", - detail: "credential token abc123 was rejected", - }, - }), - ), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("bitbucket"), - repository: "t3code", - }), - ); - - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.notInclude(error.message, "abc123"); - }).pipe(Effect.provide(layer({ provider }))); -}); - -it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () => - Effect.gen(function* () { - const cause = new BitbucketApi.BitbucketRequestError({ - operation: "getRepository", - cause: new Error("credential token abc123 was rejected"), - }); - const provider = yield* BitbucketSourceControlProvider.make.pipe( - Effect.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - getRepositoryCloneUrls: () => Effect.fail(cause), - }), - ), - ); - - yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const error = yield* Effect.flip( - service.lookupRepository({ - provider: SourceControlProviderKind.make("bitbucket"), - repository: "pingdotgg/t3code", - cwd: "/workspace", - }), - ); - - assert.strictEqual(error.detail, "The source control operation could not be completed."); - assert.notInclude(error.message, "abc123"); - assert.notInclude(error.message, cause.detail); - }).pipe(Effect.provide(layer({ provider }))); - }), -); - it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -530,113 +357,6 @@ it.effect("preserves destination probe failures instead of treating them as miss ); }); -it.effect("clones directly under an existing filesystem root without recreating it", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const root = path.parse(path.resolve(".")).root; - const cloneCalls: Array = []; - - yield* Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - yield* service.cloneRepository({ - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - destinationPath: path.join(root, "t3code"), - protocol: "https", - }); - - assert.deepStrictEqual(cloneCalls, [root]); - }).pipe( - Effect.provide( - layer({ - fileSystem: FileSystem.makeNoop({ - exists: (target) => Effect.succeed(target === root), - makeDirectory: (target) => - Effect.sync(() => { - assert.notStrictEqual(target, root, "must not try to create the existing root"); - }), - }), - git: { - execute: (input) => - Effect.sync(() => { - cloneCalls.push(input.cwd); - return processOutput(); - }), - }, - }), - ), - ); - }).pipe(Effect.provide(NodePath.layer)), -); - -it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { - const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; - const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; - const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; - const provider = makeProvider({ - createRepository: (input) => - Effect.sync(() => { - createCalls.push({ - cwd: input.cwd, - repository: input.repository, - visibility: input.visibility, - }); - return CLONE_URLS; - }), - }); - - return Effect.gen(function* () { - const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; - const result = yield* service.publishRepository({ - cwd: "/workspace", - provider: SourceControlProviderKind.make("github"), - repository: "octocat/t3code", - visibility: "private", - remoteName: "origin", - protocol: "ssh", - }); - - assert.deepStrictEqual(result, { - repository: { provider: SourceControlProviderKind.make("github"), ...CLONE_URLS }, - remoteName: "origin", - remoteUrl: CLONE_URLS.sshUrl, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - status: "pushed", - }); - assert.deepStrictEqual(createCalls, [ - { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, - ]); - assert.deepStrictEqual(remoteCalls, [ - { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, - ]); - assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); - }).pipe( - Effect.provide( - layer({ - provider, - git: { - ensureRemote: (input) => - Effect.sync(() => { - remoteCalls.push(input); - return "origin"; - }), - pushCurrentBranch: (cwd, _fallbackBranch, options) => - Effect.sync(() => { - pushCalls.push({ cwd, remoteName: options?.remoteName }); - return { - status: "pushed" as const, - branch: "feature/remote-v1", - upstreamBranch: "origin/feature/remote-v1", - setUpstream: true, - }; - }), - }, - }), - ), - ); -}); - it.effect("publishes to the remote name returned by ensureRemote", () => { const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; @@ -711,6 +431,7 @@ it.effect("explains a rejected SSH push after creating the repository", () => }).pipe(Effect.provide(layer({ git: failingPush("authentication_failed") }))), ); +// regression: #19219 publishing did not explain why the first push failed it.effect("says the repository exists when an unclassified publish push fails", () => Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; diff --git a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts index 1c7c34d2ab80..faba4ffdc5f1 100644 --- a/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts +++ b/apps/server/src/sourceControl/gitHubChangeRequestTemplate.test.ts @@ -12,15 +12,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubChangeRequestTemplate from "@t3tools/source-control-github/server/gitHubChangeRequestTemplate"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; -const SINGLE_TEMPLATE_PATHS = [ - ".github/pull_request_template.md", - ".github/PULL_REQUEST_TEMPLATE.md", - "pull_request_template.md", - "PULL_REQUEST_TEMPLATE.md", - "docs/pull_request_template.md", - "docs/PULL_REQUEST_TEMPLATE.md", -] as const; - const TEMPLATE_DIRECTORIES = [ ".github/PULL_REQUEST_TEMPLATE", "PULL_REQUEST_TEMPLATE", @@ -83,18 +74,6 @@ const detectTemplate = (cwd: string, treeish = "HEAD") => return yield* GitHubChangeRequestTemplate.detect(cwd, treeish, git.execute); }); -it.effect.each(SINGLE_TEMPLATE_PATHS)("recognizes $0", (relativePath) => - runWithTempDirectory((cwd) => - Effect.gen(function* () { - yield* writeTemplate(cwd, relativePath, `template from ${relativePath}`); - yield* commitTemplates(cwd); - - const template = yield* detectTemplate(cwd); - assert.strictEqual(Option.getOrUndefined(template), `template from ${relativePath}`); - }), - ), -); - it.effect("reads templates from the requested base tree", () => runWithTempDirectory((cwd) => Effect.gen(function* () { diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f158..1b411c93cf63 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,23 +1,6 @@ -import { assert, expect, it } from "@effect/vitest"; +import { expect, it } from "@effect/vitest"; -import { - buildPairingUrl, - formatHeadlessServeOutput, - renderTerminalQrCode, - resolveHeadlessConnectionHost, - resolveHeadlessConnectionString, - resolveListeningPort, -} from "./startupAccess.ts"; - -it("prefers localhost when no explicit host is configured", () => { - expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); - expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); -}); - -it("keeps explicit bind hosts in the connection string", () => { - expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773"); - expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773"); -}); +import { resolveHeadlessConnectionString } from "./startupAccess.ts"; it("resolves wildcard hosts to a concrete external interface when one is available", () => { const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { @@ -45,35 +28,3 @@ it("resolves wildcard hosts to a concrete external interface when one is availab expect(connectionString).toBe("http://192.168.1.42:3773"); }); - -it("prefers the actual bound port when an http server address is available", () => { - expect(resolveListeningPort({ port: 4123 }, 3773)).toBe(4123); - expect(resolveListeningPort("pipe", 3773)).toBe(3773); - expect(resolveListeningPort(null, 3773)).toBe(3773); -}); - -it("builds a pairing URL that embeds the token in the hash", () => { - expect(buildPairingUrl("http://192.168.1.42:3773", "PAIRCODE")).toBe( - "http://192.168.1.42:3773/pair#token=PAIRCODE", - ); -}); - -it("renders terminal QR codes as a multi-line unicode block grid", () => { - const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); - - assert.isTrue(qrCode.includes("█")); - assert.isTrue(qrCode.split("\n").length > 10); -}); - -it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { - const output = formatHeadlessServeOutput({ - connectionString: "http://192.168.1.42:3773", - token: "PAIRCODE", - pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", - }); - - expect(output).toContain("Connection string: http://192.168.1.42:3773"); - expect(output).toContain("Token: PAIRCODE"); - expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); - assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); -}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index c37f6849763f..6f5db469b913 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -48,7 +48,7 @@ const isIpv4Family = (family: string | number): boolean => family === "IPv4" || const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; -export const resolveHeadlessConnectionHost = ( +const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), ): string => { @@ -83,7 +83,7 @@ export const resolveHeadlessConnectionString = ( return `http://${formatHostForUrl(connectionHost)}:${port}`; }; -export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { +const resolveListeningPort = (address: unknown, fallbackPort: number): number => { if ( typeof address === "object" && address !== null && diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index a2f92c4942bc..9579bbf490a9 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -9,13 +9,11 @@ import * as Option from "effect/Option"; import type * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as Schema from "effect/Schema"; import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import { DEFAULT_SERVER_SETTINGS, - StorageCleanupReport, type WorktreeKeepWhen, type StorageCleanupSettings, } from "@t3tools/contracts"; @@ -35,16 +33,12 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { - EventId, ProjectId, - ProviderDriverKind, ProviderInstanceId, - ProviderSessionId, RunId, RuntimeRequestId, ThreadId, type OrchestrationV2DomainEvent, - type OrchestrationV2ProviderSession, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -59,7 +53,6 @@ import { const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; -const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); @@ -129,13 +122,6 @@ describe("V2 storage cleanup eligibility", () => { }, ); - it.each(["idle", "completed", "interrupted", "failed", "cancelled", "rolled_back"] as const)( - "allows cleanup once its thread is %s", - (status) => { - expect(storageCleanupThreadIdle(candidateWithStatus(status), NOW_MS)).toBe(true); - }, - ); - it.each(["completed", "interrupted", "cancelled", "rolled_back"] as const)( "retains %s while background work is pending", (status) => { @@ -537,27 +523,6 @@ const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) => measuredPath = yield* fs.realPath(fixture.worktree); return { ...fixture, measuring, resume }; }); -const sessionUpdated = ( - index: number, - status: OrchestrationV2ProviderSession["status"], -): OrchestrationV2DomainEvent => ({ - id: EventId.make(`event-${index}`), - type: "provider-session.updated", - threadId: ThreadId.make("thread-1"), - occurredAt: at(0), - payload: { - id: ProviderSessionId.make(`session-${index}`), - driver: ProviderDriverKind.make("codex"), - providerInstanceId: ProviderInstanceId.make("codex"), - status, - cwd: "/elsewhere", - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: at(0), - updatedAt: at(0), - lastError: null, - }, -}); const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge( Layer.unwrap( @@ -1027,27 +992,6 @@ describe("orphan worktree reports", () => { ), ); - it.live("waits an hour before reporting a folder without a thread row", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const folder = `${fixture.config.worktreesDir}/review-guide-123/checkout`; - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "review", folder]); - expect((yield* fixture.service.runNow).entries).toEqual([]); - yield* fixture.fs.utimes(folder, 1, 1); - expect((yield* fixture.service.runNow).entries).toEqual([ - expect.objectContaining({ - path: folder, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(yield* fixture.fs.exists(`${folder}/tracked.txt`)).toBe(true); - }), - ), - ); - it.live("continues reporting when a project root cannot be probed", () => runCleanupTest( Effect.gen(function* () { @@ -1066,76 +1010,6 @@ describe("orphan worktree reports", () => { ), ); - it.effect.each(["walk-timeout", "sweep-budget"] as const)( - "caches walk timeouts and retries budget-cut orphan sizes (%s)", - (limit) => - runCleanupTest( - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const fs = yield* FileSystem.FileSystem; - const measuring = yield* Deferred.make(); - let orphan = ""; - let walks = 0; - const fixture = yield* cleanupFixture().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - readDirectory: (directory, options) => - Effect.gen(function* () { - if (directory.endsWith("/repository") && walks === 0) - yield* TestClock.adjust("1 second"); - if (directory === orphan) { - walks++; - if (limit === "walk-timeout" || walks === 1) { - yield* Deferred.succeed(measuring, undefined); - return yield* Effect.never; - } - } - return (yield* fs.readDirectory(directory, options)).sort(); - }), - }), - ); - fixture.setThreads([]); - orphan = `${fixture.config.worktreesDir}/repository/orphan`; - yield* fs.makeDirectory(orphan, { recursive: true }); - yield* fs.writeFileString(`${orphan}/file`, "x"); - const bytes = yield* allocatedBytes(fs, `${orphan}/file`); - yield* fs.utimes(orphan, 1, 1); - const later = `${fixture.config.worktreesDir}/repository/z-later`; - yield* fs.makeDirectory(later); - yield* fs.utimes(later, 1, 1); - if (limit === "sweep-budget") { - const first = `${fixture.config.worktreesDir}/repository/a-first`; - yield* fs.makeDirectory(first); - yield* fs.utimes(first, 1, 1); - } - const orphanEntry = (report: StorageCleanupReport) => - report.entries.find((entry) => entry.path === orphan); - const sweep = yield* fixture.service.runNow.pipe(Effect.forkChild); - yield* Deferred.await(measuring); - yield* TestClock.adjust(limit === "walk-timeout" ? "30 seconds" : "29 seconds"); - const firstReport = yield* Fiber.join(sweep); - expect(orphanEntry(firstReport)?.bytes).toBeNull(); - expect(firstReport.entries.find((entry) => entry.path === later)?.bytes).toBeNull(); - const nextReport = yield* fixture.service.runNow; - expect(orphanEntry(nextReport)?.bytes).toBe(limit === "walk-timeout" ? null : bytes); - expect(nextReport.entries.find((entry) => entry.path === later)?.bytes).toBe(0); - yield* fs.writeFileString(`${orphan}/file`, "changed".repeat(10_000)); - const changedBytes = yield* allocatedBytes(fs, `${orphan}/file`); - expect(changedBytes).not.toBe(bytes); - expect(orphanEntry(yield* fixture.service.runNow)?.bytes).toBe( - limit === "walk-timeout" ? null : bytes, - ); - expect(walks).toBe(limit === "walk-timeout" ? 1 : 2); - if (limit === "walk-timeout") return; - fixture.setStoredWorktreePaths([orphan]); - expect(orphanEntry(yield* fixture.service.runNow)).toBeUndefined(); - fixture.setStoredWorktreePaths([]); - expect(orphanEntry(yield* fixture.service.runNow)?.bytes).toBe(changedBytes); - expect(walks).toBe(3); - }), - ), - ); - it.live.each(["nested", "direct"] as const)( "reports a linked orphan's size without removing its files (%s)", (location) => @@ -1256,100 +1130,6 @@ describe("orphan worktree reports", () => { ), ); - it.live.each(["direct", "symlink"] as const)( - "reports an orphan under a managed root inside a project (%s)", - (location) => - runCleanupTest( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const walked: string[] = []; - const fixture = yield* cleanupFixture().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - readDirectory: (directory, options) => { - walked.push(directory); - return fs.readDirectory(directory, options); - }, - }), - ); - fixture.setThreads([]); - const root = `${fixture.repo}/.worktrees`; - const orphan = `${root}/repository/orphan`; - const source = `${fixture.repo}/src`; - yield* fs.makeDirectory(`${source}/nested/deep`, { recursive: true }); - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); - if (location === "symlink") { - const alias = `${fixture.config.worktreesDir}/project-alias`; - yield* fs.symlink(fixture.repo, alias); - fixture.setProjectRoots([alias]); - } - fixture.setSettings({ worktreesDirectory: root }); - yield* fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ - path: orphan, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(walked.some((directory) => directory.startsWith(source))).toBe(false); - expect(yield* fs.readFileString(`${orphan}/tracked.txt`)).toBe("original\n"); - }), - ), - ); - - it.live("reports an orphan containing a removed project checkout", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const orphan = `${fixture.config.worktreesDir}/repository/orphan`; - const removed = `${orphan}/removed-checkout`; - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); - yield* fixture.fs.makeDirectory(removed); - yield* fixture.fs.remove(removed, { recursive: true }); - fixture.setProjectRoots([fixture.repo, removed]); - yield* fixture.fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ - path: orphan, - outcome: "kept", - reason: "No thread uses this worktree", - }), - ]); - expect(yield* fixture.fs.readFileString(`${orphan}/tracked.txt`)).toBe("original\n"); - }), - ), - ); - - it.live("skips project workspace internals while reporting a real orphan sibling", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const project = `${fixture.config.worktreesDir}/project-checkout`; - const orphan = `${fixture.config.worktreesDir}/repository/orphan`; - yield* fixture.fs.makeDirectory(`${project}/src/nested`, { recursive: true }); - yield* fixture.fs.makeDirectory(orphan, { recursive: true }); - yield* fixture.fs.writeFileString(`${orphan}/file`, "x"); - fixture.setProjectRoots([fixture.repo, project]); - yield* fixture.fs.utimes(orphan, 1, 1); - const report = yield* fixture.service.runNow; - expect(report.entries).toEqual([ - expect.objectContaining({ - path: orphan, - outcome: "kept", - bytes: yield* allocatedBytes(fixture.fs, `${orphan}/file`), - }), - ]); - expect(yield* fixture.fs.exists(`${project}/src/nested`)).toBe(true); - expect(yield* fixture.fs.exists(orphan)).toBe(true); - }), - ), - ); - it.live("skips symbolic links at both directory levels and disabled cleanup", () => runCleanupTest( Effect.gen(function* () { @@ -1451,31 +1231,6 @@ describe("orphan worktree reports", () => { }), ), ); - - it.live("caps orphan entries while preserving kept totals", () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - for (let i = 0; i < 205; i++) { - const orphan = `${fixture.config.worktreesDir}/repository/orphan-${i}`; - yield* fixture.fs.makeDirectory(orphan, { recursive: true }); - yield* fixture.fs.writeFileString(`${orphan}/file`, "x"); - yield* fixture.fs.utimes(orphan, 1, 1); - } - const report = yield* fixture.service.runNow; - expect(report.entries).toHaveLength(200); - const bytes = yield* allocatedBytes( - fixture.fs, - `${fixture.config.worktreesDir}/repository/orphan-0/file`, - ); - expect(report.entries.every((entry) => entry.bytes === bytes)).toBe(true); - expect(report.omittedCount).toBe(5); - expect(report.counts).toEqual({ removed: 0, kept: 205, failed: 0 }); - expect(report.bytesFreed).toBe(0); - }), - ), - ); }); describe("storage cleanup reports and local file policies", () => { @@ -1568,31 +1323,58 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("falls back to file lengths when link counts and allocated blocks are unavailable", () => - runCleanupTest( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { service, worktree } = yield* cleanupFixture().pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - stat: (path) => - fs - .stat(path) - .pipe( - Effect.map((stat) => ({ ...stat, nlink: Option.none(), blocks: Option.none() })), - ), - }), - ); - const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => - fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), - ); - const bytes = sizes.reduce((sum, size) => sum + size, 0); - const report = yield* service.runNow; - expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes }); - expect(report.bytesFreed).toBe(bytes); - expect(yield* fs.exists(worktree)).toBe(false); - }), - ), + it.live.each(["same-device", "other-device", "no-manifest"] as const)( + "counts the pnpm virtual store only when its store forces copies (%s)", + (store) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const storeDir = yield* fs.makeTempDirectoryScoped({ prefix: "pnpm-store-" }); + const { service, worktree, setPolicy } = yield* cleanupFixture().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (target) => + fs + .stat(target) + .pipe( + Effect.map((info) => + store === "other-device" && target === storeDir + ? { ...info, dev: info.dev + 1n } + : info, + ), + ), + }), + ); + setPolicy("tracked-changes"); + const nodeModules = `${worktree}/node_modules`; + yield* fs.makeDirectory(`${nodeModules}/.pnpm/pkg@1.0.0`, { recursive: true }); + yield* fs.makeDirectory(`${nodeModules}/.vite`); + yield* fs.writeFileString( + `${nodeModules}/.pnpm/pkg@1.0.0/index.js`, + "clone ".repeat(10_000), + ); + yield* fs.writeFileString(`${nodeModules}/.vite/deps.js`, "cache"); + if (store !== "no-manifest") + yield* fs.writeFileString( + `${nodeModules}/.modules.yaml`, + JSON.stringify({ storeDir, virtualStoreDir: ".pnpm" }), + ); + const counted = [ + ...(yield* fs.readDirectory(worktree)).flatMap((name) => + name === "node_modules" ? [] : [`${worktree}/${name}`], + ), + `${nodeModules}/.vite/deps.js`, + ...(store === "no-manifest" ? [] : [`${nodeModules}/.modules.yaml`]), + ...(store === "same-device" ? [] : [`${nodeModules}/.pnpm/pkg@1.0.0/index.js`]), + ]; + const bytes = (yield* Effect.forEach(counted, (file) => allocatedBytes(fs, file))).reduce( + (sum, size) => sum + size, + 0, + ); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes }); + }), + ), ); it.live("still removes a worktree when measuring its size fails", () => runCleanupTest( @@ -1668,41 +1450,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("reads the thread snapshot once per sweep, not per worktree", () => - runCleanupTest( - Effect.gen(function* () { - const { service, snapshotReads } = yield* cleanupFixture(); - expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); - expect(snapshotReads()).toBe(1); - }), - ), - ); - it.live.each([ - ["running", "ready", 1], - ["stopped", "error", 2], - ] as const)( - "session updates to %s or %s during a sweep make %i sweep(s) in total", - ([first, second, sweeps]) => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture(); - const handled = yield* Deferred.make(); - yield* fixture.activate( - Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( - Stream.flatMap(() => Stream.range(0, 19)), - Stream.map((index) => sessionUpdated(index, index % 2 === 0 ? first : second)), - Stream.concat( - Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), - ), - ), - ); - yield* Deferred.await(handled); - yield* Deferred.succeed(fixture.resume, undefined); - yield* fixture.service.drain; - expect(fixture.snapshotReads()).toBe(sweeps); - }), - ), - ); it.live("does not measure a worktree kept for an open provider session", () => runCleanupTest( Effect.gen(function* () { @@ -1774,27 +1521,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it("decodes reports from servers without size or project fields", () => { - const report = decodeCleanupReport({ - trigger: "manual", - startedAt: "2026-06-10T12:00:00.000Z", - finishedAt: "2026-06-10T12:00:01.000Z", - entries: [ - { - kind: "worktree", - outcome: "removed", - reason: "Removed", - path: null, - threadId: null, - threadTitle: null, - }, - ], - counts: { removed: 1, kept: 0, failed: 0 }, - omittedCount: 0, - }); - expect(report.bytesFreed).toBe(0); - expect(report.entries[0]).toMatchObject({ bytes: null, files: null, projectPath: null }); - }); it.live.each([ ["any-local-files", ".env", "kept"], ["uncommitted-changes", ".env", "removed"], @@ -1934,21 +1660,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live("streams the current report and subsequent runs", () => - runCleanupTest( - Effect.gen(function* () { - const { service } = yield* cleanupFixture(); - const reports = yield* service.reports.pipe( - Stream.take(2), - Stream.tap((report) => (report === null ? service.runNow : Effect.void)), - Stream.runCollect, - ); - expect(reports[0]).toBeNull(); - expect(reports[1]).toEqual(yield* service.latestReport); - expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); - }), - ), - ); it.live("caps entries while preserving totals and prioritizing removals", () => runCleanupTest( Effect.gen(function* () { @@ -1993,40 +1704,6 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live( - "reports shared and running worktrees but omits disabled rules and missing directories", - () => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* cleanupFixture(); - const { service, fs, worktree, setThreads } = fixture; - setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); - expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); - setThreads([ - shell({ branch: "feature", worktreePath: worktree }), - shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), - ]); - expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); - setThreads([ - shell({ branch: "feature", worktreePath: worktree }), - shell({ - id: ThreadId.make("thread-2"), - branch: "feature", - worktreePath: worktree, - archivedAt: at(-DAY_MS), - }), - ]); - expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); - expect(yield* fs.exists(worktree)).toBe(true); - fixture.disable(); - expect((yield* service.runNow).entries).toEqual([]); - yield* fs.remove(worktree, { recursive: true }); - expect( - (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), - ).toEqual([]); - }), - ), - ); it.live("serializes concurrent requests and returns each run's report", () => runCleanupTest( Effect.gen(function* () { @@ -2104,102 +1781,6 @@ describe("storage cleanup reports and local file policies", () => { ); }); -describe("settled worktree retention", () => { - it.effect.each([ - "immediate", - "settle-script", - "expired", - "boundary", - "wait", - "recent-update", - "active", - "dirty", - "project-off", - "project-custom", - "inactive-settling", - ] as const)("applies retention and safety checks (%s)", (scenario) => - Effect.gen(function* () { - yield* TestClock.setTime(NOW_MS); - const fixture = yield* cleanupFixture(); - const days = ["wait", "boundary", "expired", "recent-update"].includes(scenario) ? 8 : 0; - const rules = { - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: - scenario === "project-custom" || scenario === "inactive-settling" ? null : days, - worktreeAfterDays: scenario === "inactive-settling" ? 1 : null, - }; - fixture.setSettings({ - storageCleanup: rules, - projectSettingsOverrides: { - [ProjectId.make("project-1")]: - scenario === "project-off" - ? { worktreeCleanup: { mode: "off" } } - : scenario === "project-custom" - ? { - worktreeCleanup: { - mode: "custom", - rules: { ...rules, worktreeSettledAfterDays: 0 }, - }, - } - : {}, - }, - }); - const thread = shell({ - branch: "feature", - worktreePath: fixture.worktree, - settledAt: - scenario === "active" - ? null - : at( - scenario === "expired" || - scenario === "recent-update" || - scenario === "inactive-settling" - ? -20 * DAY_MS - : scenario === "boundary" - ? -8 * DAY_MS - : scenario === "wait" - ? -8 * DAY_MS + 1 - : scenario === "settle-script" - ? 0 - : -60_000, - ), - updatedAt: at( - scenario === "recent-update" || - scenario === "settle-script" || - scenario === "inactive-settling" - ? 0 - : -10 * DAY_MS, - ), - }); - fixture.setThreads([thread]); - if (scenario === "dirty") - yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "edit\n"); - const report = yield* fixture.service.runNow; - const removed = ["immediate", "expired", "boundary", "project-custom"].includes(scenario); - expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed); - expect(report.counts.removed).toBe(removed ? 1 : 0); - if (scenario !== "project-off") { - const reason = - scenario === "active" - ? "Not settled" - : scenario === "wait" - ? "Settled for 7 of 8 days" - : ["settle-script", "recent-update", "inactive-settling"].includes(scenario) - ? "Settled moments ago" - : scenario === "dirty" - ? "Has uncommitted changes (1 file)" - : `Settled for ${scenario === "expired" ? 20 : scenario === "boundary" ? 8 : 0} days`; - expect(report.entries[0]).toMatchObject({ outcome: removed ? "removed" : "kept", reason }); - } - expect(thread.branch).toBe("feature"); - expect( - (yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"])) - .exitCode, - ).toBe(0); - }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), - ); -}); - describe("settled worktree rechecks", () => { it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( "keeps a checkout changed during measurement (%j)", @@ -2252,52 +1833,3 @@ it.live("keeps saved usage data when removing a settled checkout", () => }), ), ); - -it.live.each(["thread.settled", "stopped", "ready"] as const)( - "leaves settled cleanup to the regular sweeps after %s", - (trigger) => - runCleanupTest( - Effect.gen(function* () { - const fixture = yield* pausedMeasurementFixture({ - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: 0, - }); - fixture.setSettings({ - storageCleanup: { - ...DEFAULT_SERVER_SETTINGS.storageCleanup, - worktreeSettledAfterDays: 0, - }, - }); - const thread = shell({ - branch: "feature", - worktreePath: fixture.worktree, - settledAt: at(0), - }); - fixture.setThreads([thread]); - const handled = yield* Deferred.make(); - yield* fixture.activate( - Stream.fromEffect(Deferred.await(fixture.measuring)).pipe( - Stream.flatMap(() => Stream.range(0, 19)), - Stream.map((index): OrchestrationV2DomainEvent => - trigger === "thread.settled" - ? { - id: EventId.make(`settled-${index}`), - type: "thread.settled", - threadId: thread.id, - occurredAt: at(0), - payload: { ...thread, lastVisitedAt: null }, - } - : sessionUpdated(index, trigger), - ), - Stream.concat( - Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain), - ), - ), - ); - yield* Deferred.await(handled); - yield* Deferred.succeed(fixture.resume, undefined); - yield* fixture.service.drain; - expect(fixture.snapshotReads()).toBe(1); - }), - ), -); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 99b606b3ba3f..cbaecb660b14 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -36,6 +36,7 @@ import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import { parse as parseYaml } from "yaml"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -64,6 +65,13 @@ const decodeCleanupThread = Schema.decodeUnknownEffect( const decodeCleanupSession = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2ProviderSessionJson), ); +// pnpm 11 writes this file as JSON, older versions as YAML. +const decodePnpmModulesManifest = Schema.decodeUnknownEffect( + Schema.Struct({ + storeDir: Schema.String, + virtualStoreDir: Schema.optional(Schema.String), + }), +); const DAY_MS = 86_400_000; const isTextGenerationError = Schema.is(TextGenerationError); @@ -527,14 +535,35 @@ const make = Effect.gen(function* () { ); }); + // pnpm imports packages by clone or reflink when its store shares a device + // with the worktree. Those files have one link but share blocks with the + // store, so removing them frees almost nothing. A store on another device + // forces real copies, which still count. + const sharedPnpmVirtualStore = Effect.fnUntraced( + function* (root: string) { + const nodeModules = path.join(root, "node_modules"); + const text = yield* fs.readFileString(path.join(nodeModules, ".modules.yaml")); + const manifest = yield* decodePnpmModulesManifest(yield* Effect.try(() => parseYaml(text))); + const virtualStore = path.resolve(nodeModules, manifest.virtualStoreDir ?? ".pnpm"); + const [store, virtual] = yield* Effect.all([ + fs.stat(manifest.storeDir), + fs.stat(virtualStore), + ]); + return store.dev === virtual.dev ? virtualStore : null; + }, + Effect.orElseSucceed(() => null), + ); + const measureWorktree = Effect.fn("storageCleanup.measureWorktree")( function* (worktreePath: string) { const root = yield* fs.realPath(worktreePath); + const skipped = yield* sharedPnpmVirtualStore(root); const pending = [root]; let entries = 1; let bytes = 0; while (pending.length > 0) { const target = pending.pop()!; + if (target === skipped) continue; // Effect's stat follows links. Probe with readLink first to get lstat // semantics, including skipping dangling links and directory links. const isLink = yield* fs.readLink(target).pipe( diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 6a07d9decc6d..1cc66be7b3df 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,6 +1,6 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it, vi } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -75,14 +75,6 @@ type Respond = ( request: HttpClientRequest.HttpClientRequest, ) => Effect.Effect; -/** The connection drops after the endpoint read the batch, so it may be stored. */ -const connectionReset: Respond = (request) => - Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, cause: "connection reset" }), - }), - ); - const respondWithStatus = (status: number): Respond => (request) => @@ -152,94 +144,7 @@ const sendForTenMinutes = ( return batches; }); -it("retryDelayMs doubles from 2s and stays under the 5 minute cap", () => { - assert.equal(AnalyticsService.retryDelayMs(1, 0), 1_000); - assert.equal(AnalyticsService.retryDelayMs(2, 0.999_999), 4_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0), 150_000); - assert.equal(AnalyticsService.retryDelayMs(30, 0.999_999), 300_000); -}); - it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("a batch that keeps failing is retried with the same uuids, then dropped", () => - Effect.gen(function* () { - const failures = { - "connection reset": connectionReset, - "408": respondWithStatus(408), - "429": respondWithStatus(429), - "503": respondWithStatus(503), - }; - for (const [failure, respond] of Object.entries(failures)) { - const batches = yield* sendForTenMinutes(respond); - assert.equal(batches.length, 5, failure); - const uuids = batches.map((batch) => batch.map((event) => event.uuid).join(",")); - assert.equal(new Set(uuids).size, 1, `${failure}: every retry carries the same uuids`); - assert.equal(new Set(batches[0]?.map((event) => event.uuid)).size, 20, failure); - } - }), - ); - - it.effect("a batch the endpoint rejects is dropped without a retry", () => - Effect.gen(function* () { - for (const status of [400, 401, 413]) { - const batches = yield* sendForTenMinutes(respondWithStatus(status)); - assert.equal(batches.length, 1, `status ${status}`); - } - }), - ); - - it.effect("events carry the build's telemetry channel, or dev when none is set", () => - Effect.gen(function* () { - const channels = (batches: ReadonlyArray>) => [ - ...new Set(batches.flat().map((event) => event.properties.telemetryChannel)), - ]; - assert.deepEqual(channels(yield* sendForTenMinutes(respondWithStatus(204))), ["dev"]); - - // Stands in for the value the bundler writes in at build time. - vi.stubGlobal("__T3CODE_TELEMETRY_CHANNEL__", "nightly"); - assert.deepEqual(channels(yield* sendForTenMinutes(respondWithStatus(204))), ["nightly"]); - }).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllGlobals()))), - ); - - it.effect("each owner is sent once per process, and every event carries the install id", () => - Effect.gen(function* () { - let identity: Effect.Success | null = null; - const batches = yield* sendForTenMinutes( - respondWithStatus(204), - Effect.gen(function* () { - identity = yield* getTelemetryIdentity; - const analytics = yield* AnalyticsService.AnalyticsService; - const connect = { source: "connect", ownerId: "a".repeat(64) } as const; - yield* analytics.recordOwners([connect, connect], "server"); - yield* analytics.recordOwners( - [connect, { source: "device", ownerId: "b".repeat(64) }], - "client", - ); - }), - ); - const events = batches.flat(); - - assert.deepEqual( - events.map((event) => [ - event.event, - event.properties.ownerSource, - event.properties.observedBy, - ]), - [ - ["install.owner_observed", "connect", "server"], - ["install.owner_observed", "connect", "client"], - ["install.owner_observed", "device", "client"], - ], - ); - const { distinctId, installId } = identity!; - assert.isString(installId); - assert.isTrue( - events.every( - (event) => event.distinct_id === distinctId && event.properties.installId === installId, - ), - ); - }), - ); - it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { const batches = yield* sendForTenMinutes( diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 82226c441398..8f402b6682f2 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -66,7 +66,7 @@ const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; /** `random` is in [0, 1). */ -export function retryDelayMs(failures: number, random: number): number { +function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); return Math.round(ceiling / 2 + (ceiling / 2) * random); } diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts deleted file mode 100644 index 11127e19c2d9..000000000000 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { expect, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; -import * as AnalyticsService from "./AnalyticsService.ts"; -import * as DelegatedTaskAnalytics from "./DelegatedTaskAnalytics.ts"; - -const parentThreadId = ThreadId.make("parent-private"); - -const subagentEvent = (status: string, origin = "app_owned") => - ({ - type: "subagent.updated", - payload: { - id: "task-private", - threadId: parentThreadId, - origin, - status, - providerInstanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5-5", - prompt: "secret task", - completionWake: "always", - startedAt: DateTime.makeUnsafe(0), - completedAt: DateTime.makeUnsafe(90_000), - }, - }) as never; - -it.effect("records each delegated task's final outcome once, without ids or prompts", () => { - const recorded: Array<{ event: string; properties: unknown }> = []; - let shellLookups = 0; - return Effect.gen(function* () { - const analytics = yield* DelegatedTaskAnalytics.DelegatedTaskAnalytics; - yield* analytics.onSubagent(subagentEvent("running")); - yield* analytics.onSubagent(subagentEvent("completed", "provider_native")); - // The first parent lookup fails; the next final update still reports the task. - yield* analytics.onSubagent(subagentEvent("completed")); - yield* analytics.onSubagent(subagentEvent("completed")); - yield* analytics.onSubagent(subagentEvent("completed")); - expect(recorded).toEqual([ - { - event: "mcp.delegated_task.finished", - properties: { - status: "completed", - callerProvider: "codex", - callerModel: "gpt-5.5", - targetProvider: "claudeAgent", - targetModel: "claude-opus-5-5", - crossProvider: true, - completionWake: "always", - durationSeconds: 90, - }, - }, - ]); - }).pipe( - Effect.provide( - DelegatedTaskAnalytics.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.empty, - getThreadShell: () => - ++shellLookups === 1 - ? Effect.die("thread store unavailable") - : Effect.succeed({ - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.5", - }, - } as never), - }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - { - instanceId: ProviderInstanceId.make("codex"), - driver: "codex", - models: [{ slug: "gpt-5.5", isCustom: false }], - } as never, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: "claudeAgent", - models: [{ slug: "claude-opus-5-5", isCustom: false }], - } as never, - ]), - }), - Layer.succeed( - AnalyticsService.AnalyticsService, - AnalyticsService.AnalyticsService.of({ - record: (event, properties) => - Effect.sync(() => void recorded.push({ event, properties })), - flush: Effect.void, - recordOwners: () => Effect.void, - }), - ), - ), - ), - ), - ), - ); -}); diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts index 0be829cbb891..e19e6a1c8968 100644 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ b/apps/server/src/telemetry/FeatureUsage.test.ts @@ -1,32 +1,10 @@ -import { CommandId, ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; +import { ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import { featureUsage } from "./FeatureUsage.ts"; const dispatch = ORCHESTRATION_V2_WS_METHODS.dispatchCommand; const threadId = ThreadId.make("thread-private"); -const commandId = CommandId.make("command-private"); - -it("names user commands with their closed-enum variant and nothing else", () => { - expect( - featureUsage(dispatch, { - type: "checkpoint.rollback", - commandId, - threadId, - scopeId: "scope-private", - checkpointId: "checkpoint-private", - restoreFiles: false, - }), - ).toEqual({ feature: "checkpoint.rollback", variant: "conversation_only" }); - expect( - featureUsage(dispatch, { - type: "thread.runtime-mode.set", - commandId, - threadId, - runtimeMode: "full-access", - }), - ).toEqual({ feature: "thread.runtime_mode.set", variant: "full-access" }); -}); it("ignores messages, housekeeping commands, and untracked RPCs", () => { expect(featureUsage(dispatch, { type: "message.dispatch", text: "secret prompt" })).toBe( diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index fac476b15cc7..6fa6c8500817 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -97,22 +97,6 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => { }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-install-"))), ); - it.effect("keeps a provider distinct_id when the install id cannot be saved", () => - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const homeDirectory = path.join(config.baseDir, "home"); - yield* fileSystem.makeDirectory(config.anonymousIdPath); - yield* writeHomeFile(homeDirectory, ".claude.json", `{"userID":"claude-machine"}`); - - assert.deepEqual(yield* Identify.getTelemetryIdentityForHome(homeDirectory), { - distinctId: yield* sha256("claude-machine"), - installId: null, - }); - }).pipe(Effect.provide(layerIdentity("t3-telemetry-identify-noinstall-"))), - ); - it.effect("leaves no torn anonymous id behind when its write fails midway", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/telemetry/ProviderDimensions.test.ts b/apps/server/src/telemetry/ProviderDimensions.test.ts index 67b33fe6f962..d88010f600e3 100644 --- a/apps/server/src/telemetry/ProviderDimensions.test.ts +++ b/apps/server/src/telemetry/ProviderDimensions.test.ts @@ -1,13 +1,7 @@ import { ProviderInstanceId, ThreadId, type ServerProvider } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import { - type AgentThread, - agentToolProperties, - handoffSettings, - callerOrigin, - toolOutcome, -} from "./ProviderDimensions.ts"; +import { type AgentThread, agentToolProperties, handoffSettings } from "./ProviderDimensions.ts"; const provider = ( instanceId: string, @@ -43,79 +37,6 @@ const thread = ( ...overrides, }); -it("relates the calling agent to each agent that received work", () => { - expect( - agentToolProperties({ - tool: "create_threads", - providers, - caller: thread("work-codex", "gpt-5.5", { createdBy: "agent", creationSource: "mcp" }), - callerDepth: 1, - targets: [ - thread("claudeAgent", "claude-opus-5-5", { interactionMode: "plan" }), - thread("work-codex", "gpt-5.5"), - ], - outcome: { outcome: "ok" }, - settings: { batchSize: 2, targetChosen: true }, - durationMs: 42, - }), - ).toEqual([ - { - tool: "create_threads", - callerProvider: "codex", - callerModel: "gpt-5.5", - callerOrigin: "agent", - callerDepth: 1, - durationMs: 42, - outcome: "ok", - batchSize: 2, - targetChosen: true, - targetProvider: "claudeAgent", - targetModel: "claude-opus-5-5", - targetRuntimeMode: "full-access", - targetInteractionMode: "plan", - crossProvider: true, - }, - { - tool: "create_threads", - callerProvider: "codex", - callerModel: "gpt-5.5", - callerOrigin: "agent", - callerDepth: 1, - durationMs: 42, - outcome: "ok", - batchSize: 2, - targetChosen: true, - targetProvider: "codex", - targetModel: "gpt-5.5", - targetRuntimeMode: "full-access", - targetInteractionMode: "default", - crossProvider: false, - }, - ]); -}); - -it("reports a non-handoff tool from the credential's provider alone", () => { - expect( - agentToolProperties({ - tool: "preview_click", - providers, - caller: undefined, - callerProviderInstanceId: ProviderInstanceId.make("claudeAgent"), - targets: [], - outcome: { outcome: "error", errorCode: "capability_denied" }, - durationMs: 3, - }), - ).toEqual([ - { - tool: "preview_click", - callerProvider: "claudeAgent", - durationMs: 3, - outcome: "error", - errorCode: "capability_denied", - }, - ]); -}); - it("omits custom, user-configured, and unknown models and instance names", () => { expect( agentToolProperties({ @@ -146,40 +67,6 @@ it("omits custom, user-configured, and unknown models and instance names", () => ]); }); -it("reads handoff settings from enums and flags, never from prompts or ids", () => { - expect( - handoffSettings( - "delegate_task", - { task: "secret", mode: "wait", target: { driverKind: "claudeAgent" } }, - { taskId: "t", waitTimedOut: true }, - ), - ).toEqual({ targetChosen: true, mode: "wait", waitTimedOut: true }); - expect(handoffSettings("delegate_task", { task: "secret" }, { waitTimedOut: false })).toEqual({ - targetChosen: false, - mode: "async", - }); - expect( - handoffSettings( - "t3_thread_launch", - { message: "secret", workspaceStrategy: { type: "worktree", branch: "private" } }, - {}, - ), - ).toEqual({ targetChosen: false, workspace: "worktree" }); - expect(handoffSettings("t3_thread_launch", { scratch: true }, {})).toMatchObject({ - workspace: "scratch", - }); - expect( - handoffSettings( - "create_threads", - { threads: [{ prompt: "a" }, { prompt: "b", target: {} }] }, - {}, - ), - ).toEqual({ batchSize: 2, targetChosen: true }); - expect(handoffSettings("t3_thread_send", { message: "secret" }, { delivery: "steered" })).toEqual( - { delivery: "steered" }, - ); -}); - it("drops input strings outside the schema's literals, since rejected calls are recorded too", () => { expect(handoffSettings("delegate_task", { mode: "/home/me/secret" }, undefined)).toEqual({ targetChosen: false, @@ -189,38 +76,3 @@ it("drops input strings outside the schema's literals, since rejected calls are handoffSettings("t3_thread_launch", { workspaceStrategy: { type: "/home/me/secret" } }, {}), ).toEqual({ targetChosen: false, workspace: "root" }); }); - -it("reports crossProvider only when both providers resolve", () => { - const [event] = agentToolProperties({ - tool: "t3_thread_send", - providers, - caller: thread("work-codex", "gpt-5.5"), - targets: [thread("removed-instance", "gpt-5.5")], - outcome: { outcome: "ok" }, - }); - expect(event).toMatchObject({ callerProvider: "codex", targetProvider: "unknown" }); - expect(event).not.toHaveProperty("crossProvider"); -}); - -it("reads failure codes from declared tool errors even without isError", () => { - expect( - toolOutcome({ - structuredContent: { - _tag: "OrchestratorMcpFailure", - code: "capability_denied", - message: "private detail", - }, - }), - ).toEqual({ outcome: "error", errorCode: "capability_denied" }); - expect(toolOutcome({ isError: false, structuredContent: { threadId: "t" } })).toEqual({ - outcome: "ok", - }); - expect(toolOutcome(undefined)).toEqual({ outcome: "error", errorCode: "exception" }); -}); - -it("tells user, agent, system, and scheduled work apart", () => { - expect(callerOrigin({ thread: { createdBy: "user" }, scheduledRun: false })).toBe("user"); - expect(callerOrigin({ thread: { createdBy: "agent" }, scheduledRun: false })).toBe("agent"); - expect(callerOrigin({ thread: { createdBy: "system" }, scheduledRun: false })).toBe("system"); - expect(callerOrigin({ thread: { createdBy: "user" }, scheduledRun: true })).toBe("scheduler"); -}); diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts index 4a668ee5af78..4e0447a1e2c4 100644 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ b/apps/server/src/telemetry/ProviderDimensions.ts @@ -78,7 +78,7 @@ export const MAX_REPORTED_DEPTH = 5; * Who started the work that made this call. Scheduled runs keep the task * creator's provenance, so the run's message marks them instead. */ -export function callerOrigin(input: { +function callerOrigin(input: { readonly thread: Pick; readonly scheduledRun: boolean; }) { diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5962f7f30d9a..37598acfe1e2 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,4 +1,3 @@ -import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -7,38 +6,27 @@ import { ProjectId, type TerminalAttachStreamEvent, type TerminalEvent, - type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, ProviderDriverKind, ProviderInstanceId, - ServerSettingsError, TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Data from "effect/Data"; -import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Path from "effect/Path"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as References from "effect/References"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; import { expect, vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -49,12 +37,6 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; -import { - applyTerminalAttachStreamEvent, - DEFAULT_MAX_TERMINAL_BUFFER_BYTES, - nextTerminalAttachSeedState, - terminalOutputText, -} from "../../../../packages/client-runtime/src/state/terminalSession.ts"; vi.mock("effect/SynchronizedRef", async (importOriginal) => { const original = await importOriginal(); @@ -62,19 +44,9 @@ vi.mock("effect/SynchronizedRef", async (importOriginal) => { }); // The server omits DOM types, which include TypeScript's WebAssembly declarations. -declare const WebAssembly: { - instantiate( - bytes: ArrayBuffer, - imports: { env: { log: () => void } }, - ): Promise<{ instance: { exports: Record } }>; -}; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ - readonly message: string; -}> {} - class FakePtyProcess implements PtyAdapter.PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; @@ -226,26 +198,6 @@ class GatedPtyAdapter extends FakePtyAdapter { } } -const waitFor = ( - predicate: Effect.Effect, - timeout: Duration.Input = 800, -): Effect.Effect => - predicate.pipe( - Effect.filterOrFail( - (done) => done, - () => new WaitForConditionError({ message: "Condition not met" }), - ), - Effect.retry(Schedule.spaced("15 millis")), - Effect.timeoutOption(timeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), - onSome: () => Effect.void, - }), - ), - ); - function openInput(overrides: Partial = {}): TerminalOpenInput { return { threadId: "thread-1", @@ -273,23 +225,6 @@ const historyLogPath = (logsDir: string, threadId = "thread-1") => Effect.map(({ join }) => join(logsDir, `terminal_${Base64Url.encode(threadId)}.log`)), ); -const multiTerminalHistoryLogPath = ( - logsDir: string, - threadId = "thread-1", - terminalId = DEFAULT_TERMINAL_ID, -) => - Effect.service(Path.Path).pipe( - Effect.map(({ join }) => { - const threadPart = `terminal_${Base64Url.encode(threadId)}`; - return join( - logsDir, - terminalId === DEFAULT_TERMINAL_ID - ? `${threadPart}.log` - : `${threadPart}_${Base64Url.encode(terminalId)}.log`, - ); - }), - ); - interface CreateManagerOptions { shellResolver?: () => string; env?: NodeJS.ProcessEnv; @@ -483,19 +418,6 @@ it("bounds long partial lines and joins surrogate pairs across chunk boundaries" } }); -it("preserves retained lines as older storage is compacted", () => { - for (const maxLines of [3, 5_000]) { - let expected = ""; - const history = new TerminalManager.BoundedTerminalHistory(maxLines, expected); - for (let batch = 0; batch < 40; batch += 1) { - const chunk = Array.from({ length: 300 }, (_, line) => `${batch}:${line}\n`).join(""); - history.append(chunk); - expected = retainedHistory(expected + chunk, maxLines); - expect(history.value()).toBe(expected); - } - } -}); - it.layer( Layer.merge(NodeServices.layer, ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer))), { excludeTestServices: true }, @@ -517,32 +439,6 @@ it.layer( }), ); - it.effect("attaches to running sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - - yield* manager.open(openInput()); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 100, - rows: 40, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.threadId, "thread-1"); - assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -671,91 +567,6 @@ it.layer( }), ); - it.effect("attaches to exited sessions without restarting them", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", - }, - worktreePath: "/tmp/should-not-restart", - }), - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "exited"); - assert.equal(snapshot.snapshot.worktreePath, null); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - }), - ); - - it.effect("restarts inactive sessions from attach only when requested", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - "1200 millis", - ); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - ...openInput({ - env: { - T3CODE_WORKTREE_PATH: "/tmp/restart-requested", - }, - worktreePath: "/tmp/restart-requested", - }), - restartIfNotRunning: true, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - assert.equal(snapshot.snapshot.status, "running"); - assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - const makeDirectory = (filePath: string) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => - fs.makeDirectory(filePath, { recursive: true }), - ); - - const chmod = (filePath: string, mode: number) => - Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.chmod(filePath, mode)); - const pathExists = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.exists(filePath)); @@ -767,65 +578,6 @@ it.layer( fs.writeFileString(filePath, contents), ); - it.effect("reports a missing cwd without an artificial cause", () => - Effect.gen(function* () { - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const cwd = path.join(baseDir, "missing-cwd"); - const error = yield* Effect.flip(manager.open(openInput({ cwd }))); - - expect(error).toMatchObject({ - _tag: "TerminalCwdNotFoundError", - cwd, - }); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("reports a cwd that is not a directory", () => - Effect.gen(function* () { - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const cwd = path.join(baseDir, "cwd-file"); - yield* writeFileString(cwd, "not a directory"); - const error = yield* Effect.flip(manager.open(openInput({ cwd }))); - - expect(error).toMatchObject({ - _tag: "TerminalCwdNotDirectoryError", - cwd, - }); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("preserves non-notFound cwd stat failures", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - - const path = yield* Path.Path; - - const { manager, baseDir } = yield* createManager(); - const blockedRoot = path.join(baseDir, "blocked-root"); - const blockedCwd = path.join(blockedRoot, "cwd"); - yield* makeDirectory(blockedCwd); - yield* chmod(blockedRoot, 0o000); - - const error = yield* Effect.flip(manager.open(openInput({ cwd: blockedCwd }))).pipe( - Effect.ensuring(chmod(blockedRoot, 0o755).pipe(Effect.ignore)), - ); - - expect(error).toMatchObject({ - _tag: "TerminalCwdStatError", - cwd: blockedCwd, - cause: { - _tag: "PlatformError", - }, - }); - }), - ); - it.effect("handles an exit replayed during subscription after publishing startup", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); @@ -856,1191 +608,153 @@ it.layer( }), ); - it.effect("supports asynchronous PTY spawn effects", () => + it.effect("supports multiple terminals per thread independently", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "term-2" })); - const snapshot = yield* manager.open(openInput()); + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - expect(ptyAdapter.processes).toHaveLength(1); + yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); }), ); - it.effect("forwards write and resize to active pty process", () => + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(layerWithHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "ls\n", - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); + yield* manager.closeIdle({ threadId: "thread-1" }); - expect(process.writes).toEqual(["ls\n"]); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); }), ); - it.effect("preserves structured context and causes for PTY I/O failures", () => + it.effect("keeps terminals that get input or output while closeIdle checks them", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const writeCause = new Error("PTY input handle is unavailable"); - process.writeFailure = writeCause; - const writeError = yield* Effect.flip( - manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "secret input that must not be attached to the error", - }), - ); - - expect(writeError).toMatchObject({ - _tag: "TerminalWriteError", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - terminalPid: process.pid, + const ptyAdapter = new FakePtyAdapter(); + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), }); - expect(writeError.cause).toBe(writeCause); - expect(writeError).not.toHaveProperty("data"); - - const resizeCause = new Error("PTY resize handle is unavailable"); - process.resizeFailure = resizeCause; - const resizeError = yield* Effect.flip( - manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 132, - rows: 40, - }), + const outputSeen = yield* Deferred.make(); + const unsubscribeOutput = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputSeen, undefined).pipe(Effect.asVoid) + : Effect.void, ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOutput)); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* Deferred.await(outputSeen); + }).pipe(Effect.orDie); - expect(resizeError).toMatchObject({ - _tag: "TerminalResizeError", - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - terminalPid: process.pid, - cols: 132, - rows: 40, - }); - expect(resizeError.cause).toBe(resizeCause); + yield* manager.closeIdle({ threadId: "thread-1" }); - process.resizeFailure = undefined; - yield* manager.open(openInput({ cols: 132, rows: 40 })); - expect(process.resizeCalls).toEqual([{ cols: 132, rows: 40 }]); + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); }), ); - it.effect("ignores delayed resize requests after a terminal closes", () => + it.effect("caps incrementally appended history without losing partial or empty lines", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); + const { manager, ptyAdapter } = yield* createManager(3); yield* manager.open(openInput()); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; - yield* manager.close({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - deleteHistory: true, - }); - yield* manager.resize({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cols: 120, - rows: 30, - }); + process.emitData("line1\n"); + process.emitData("\n"); + process.emitData("line3"); + process.emitData("-continued\nline4"); + yield* manager.close({ threadId: "thread-1" }); - expect(process.resizeCalls).toEqual([]); + const reopened = yield* manager.open(openInput()); + expect(reopened.history).toBe("\nline3-continued\nline4"); }), ); - it.effect("resizes running terminal on open when a different size is requested", () => + it.effect("bounds persisted and attached history without truncating live output", () => Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ cols: 100, rows: 24 })); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const reopened = yield* manager.open(openInput({ cols: 120, rows: 30 })); + const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const writes = ["a".repeat(32), "😀\rEND"]; + const process = ptyAdapter.processes[0]!; + for (const text of writes) process.emitData(text); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND"); - assert.equal(reopened.status, "running"); - expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]); + const reopened = yield* manager.open(openInput()); + const events = yield* Ref.get(attachEvents); + expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual( + writes, + ); + const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; + expect(snapshot?.history).toBe("aa😀\rEND"); + expect(snapshot?.sequence).toBe(reopened.sequence); }), ); - it.effect("supports multiple terminals per thread independently", () => + it.effect("retains plain Unicode and control characters around a split query", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "term-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - yield* manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); - yield* manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); - - expect(first.writes).toEqual(["pwd\n"]); - expect(second.writes).toEqual(["ls\n"]); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - }), - ); - - it.effect("clears transcript and emits cleared event", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* manager.clear({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID }); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - - const events = yield* getEvents; - expect(events.some((event) => event.type === "cleared")).toBe(true); - expect( - events.some( - (event) => - event.type === "cleared" && - event.threadId === "thread-1" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ).toBe(true); - }), - ); - - it.effect("restarts terminal with empty transcript and respawns pty", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - firstProcess.emitData("before restart\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - const snapshot = yield* manager.restart(restartInput()); - assert.equal(snapshot.history, ""); - assert.equal(snapshot.status, "running"); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - Effect.map((text) => text === ""), - ), - ); - }), - ); - - it.effect("restarts a running session when open is called with a different cwd", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const originalCwd = path.join(baseDir, "original"); - const differentCwd = path.join(baseDir, "different"); - yield* makeDirectory(originalCwd); - yield* makeDirectory(differentCwd); - - yield* manager.open(openInput({ cwd: originalCwd })); - const firstProcess = ptyAdapter.processes[0]; - expect(firstProcess).toBeDefined(); - if (!firstProcess) return; - - firstProcess.emitData("before reopen\n"); - const logPath = yield* historyLogPath(logsDir); - yield* waitFor(pathExists(logPath)); - - const reopened = yield* manager.open(openInput({ cwd: differentCwd })); - - expect(ptyAdapter.spawnInputs).toHaveLength(2); - assert.equal(firstProcess.killed, true); - assert.equal(reopened.cwd, differentCwd); - assert.equal(reopened.history, ""); - yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); - }), - ); - - it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => - Effect.gen(function* () { - const { manager, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); - const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); - yield* makeDirectory(firstWorktreePath); - yield* makeDirectory(secondWorktreePath); - const startedSnapshot = yield* manager.open( - openInput({ - cwd: firstWorktreePath, - worktreePath: firstWorktreePath, - }), - ); - const restartedSnapshot = yield* manager.restart( - restartInput({ - cwd: secondWorktreePath, - worktreePath: secondWorktreePath, - }), - ); - - assert.equal(startedSnapshot.worktreePath, firstWorktreePath); - assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); - - const events = yield* getEvents; - const startedEvent = events.find( - (event): event is Extract => event.type === "started", - ); - const restartedEvent = events.find( - (event): event is Extract => - event.type === "restarted", - ); - - assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); - assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); - }), - ); - - it.effect("preserves worktree metadata when reopening an exited session", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); - const path = yield* Path.Path; - const worktreePath = path.join(baseDir, "worktrees", "feature-a"); - yield* makeDirectory(worktreePath); - - yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - - const reopenedSnapshot = yield* manager.open( - openInput({ - cwd: worktreePath, - worktreePath, - }), - ); - - assert.equal(reopenedSnapshot.worktreePath, worktreePath); - - const events = yield* getEvents; - const reopenedEvent = events - .toReversed() - .find( - (event): event is Extract => event.type === "started", - ); - - assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); - }), - ); - - it.effect("emits exited event and reopens with clean transcript after exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); - const path = yield* Path.Path; - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("old data\n"); - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), - ); - const reopened = yield* manager.open(openInput()); - - assert.equal(reopened.history, ""); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(readFileString), - ), - ).toBe(""); - }), - ); - - it.effect("ignores trailing writes after terminal exit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "\r", - }); - expect(process.writes).toEqual([]); - }), - ); - - it.effect("skips snapshots only when every terminal is quiet and wakes on input or output", () => - Effect.gen(function* () { - let snapshots = 0; - let processes: Array<{ pid: number; ppid: number; name: string }> = []; - let duringSnapshot: Effect.Effect = Effect.void; - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - processKillGraceMs: 0, - subprocessPollIntervalMs: 1_000, - processTable: Effect.gen(function* () { - snapshots += 1; - const snapshot = processes; - yield* duringSnapshot; - return snapshot; - }), - }).pipe(Effect.provide(layerWithHostPlatform("linux"))); - const activity = yield* Queue.unbounded>(); - const output = yield* Deferred.make(); - const unsubscribe = yield* manager.subscribe((event) => - event.type === "activity" - ? Queue.offer(activity, event).pipe(Effect.asVoid) - : event.type === "output" - ? Deferred.succeed(output, undefined).pipe(Effect.asVoid) - : Effect.void, - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - yield* manager.open(openInput()); - yield* manager.open(openInput({ terminalId: "second" })); - const [first, second] = ptyAdapter.processes; - const shells = ptyAdapter.processes.map(({ pid }) => ({ pid, ppid: 1, name: "zsh" })); - processes = shells; - - yield* TestClock.adjust("1 second"); - assert.equal(snapshots, 1); - yield* TestClock.adjust("1 second"); - assert.equal(snapshots, 2); - yield* TestClock.adjust("3 seconds"); - assert.equal(snapshots, 2); - expect((yield* getEvents).filter((event) => event.type === "activity")).toEqual([]); - - processes = [...shells, { pid: 100, ppid: second!.pid, name: "vim" }]; - yield* manager.write({ threadId: "thread-1", terminalId: "second", data: "vim\r" }); - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(activity)).toMatchObject({ - terminalId: "second", - hasRunningSubprocess: true, - label: "vim", - }); - yield* TestClock.adjust("2 seconds"); - assert.equal(snapshots, 5); - - processes = shells; - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(activity)).toMatchObject({ - terminalId: "second", - hasRunningSubprocess: false, - label: "second", - }); - yield* TestClock.adjust("3 seconds"); - assert.equal(snapshots, 7); - - processes = [...shells, { pid: 101, ppid: first!.pid, name: "node" }]; - first!.emitData("server started\r\n"); - yield* Deferred.await(output); - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(activity)).toMatchObject({ - terminalId: DEFAULT_TERMINAL_ID, - hasRunningSubprocess: true, - label: "node", - }); - assert.equal(snapshots, 8); - - processes = shells; - duringSnapshot = Effect.gen(function* () { - duringSnapshot = Effect.void; - yield* manager.write({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - data: "next command\r", - }); - }).pipe(Effect.orDie); - yield* TestClock.adjust("1 second"); - expect(yield* Queue.take(activity)).toMatchObject({ hasRunningSubprocess: false }); - // Input during inspection still needs two subsequent unchanged snapshots. - yield* TestClock.adjust("2 seconds"); - assert.equal(snapshots, 11); - yield* TestClock.adjust("3 seconds"); - assert.equal(snapshots, 11); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("discovers and registers a silent child within 30 quiet ticks", () => - Effect.gen(function* () { - let snapshots = 0; - let processes: Array<{ pid: number; ppid: number; name: string }> = []; - const registered = yield* Deferred.make<{ - readonly threadId: string; - readonly terminalId: string; - readonly processIds: ReadonlyArray; - }>(); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 0, - subprocessPollIntervalMs: 1_000, - processTable: Effect.sync(() => { - snapshots += 1; - return processes; - }), - registerTerminalProcesses: (input) => - input.processIds.includes(100) - ? Deferred.succeed(registered, input).pipe(Effect.asVoid) - : Effect.void, - }).pipe(Effect.provide(layerWithHostPlatform("linux"))); - const activity = yield* Queue.unbounded>(); - const unsubscribe = yield* manager.subscribe((event) => - event.type === "activity" ? Queue.offer(activity, event).pipe(Effect.asVoid) : Effect.void, - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - yield* manager.open(openInput()); - const shell = ptyAdapter.processes[0]!; - processes = [{ pid: shell.pid, ppid: 1, name: "zsh" }]; - yield* TestClock.adjust("2 seconds"); - assert.equal(snapshots, 2); - - processes = [...processes, { pid: 100, ppid: shell.pid, name: "node" }]; - yield* TestClock.adjust("30 seconds"); - assert.equal(snapshots, 3); - assert.isTrue(yield* Deferred.isDone(registered)); - expect(yield* Deferred.await(registered)).toEqual({ - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - processIds: [shell.pid, 100], - }); - expect(yield* Queue.take(activity)).toMatchObject({ - terminalId: DEFAULT_TERMINAL_ID, - hasRunningSubprocess: true, - label: "node", - }); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("does not invoke subprocess polling until a terminal session is running", () => - Effect.gen(function* () { - let checks = 0; - const { manager } = yield* createManager(5, { - subprocessInspector: () => { - checks += 1; - return Effect.succeed({ - hasRunningSubprocess: false, - childCommand: null, - processIds: [], - }); - }, - subprocessPollIntervalMs: 20, - }); - - yield* Effect.sleep("80 millis"); - assert.equal(checks, 0); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.sync(() => checks > 0), - "1200 millis", - ); - }), - ); - - it.effect("derives subprocess activity for every terminal from one shared process snapshot", () => - Effect.gen(function* () { - const runCalls: Array<{ command: string; args: ReadonlyArray }> = []; - const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join( - "\n", - ); - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: (input) => - Effect.sync(() => { - runCalls.push({ command: input.command, args: input.args }); - return { - stdout: psStdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* manager.open(openInput({ threadId: "thread-2" })); - - yield* waitFor( - Effect.map( - getEvents, - (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ) && - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "python3", - ), - ), - "1200 millis", - ); - yield* waitFor( - Effect.sync(() => runCalls.length >= 3), - "1200 millis", - ); - - expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true); - }), - ); - - it.effect("keeps last known subprocess state when the process snapshot fails", () => - Effect.gen(function* () { - let failSnapshots = false; - let failedCalls = 0; - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: () => - Effect.sync(() => { - if (failSnapshots) failedCalls += 1; - return { - stdout: failSnapshots ? "" : " 100 9000 vim", - stderr: "", - code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - failSnapshots = true; - yield* waitFor( - Effect.sync(() => failedCalls >= 3), - "1200 millis", - ); - - const activityEvents = (yield* getEvents).filter((event) => event.type === "activity"); - expect(activityEvents.length).toBeGreaterThan(0); - expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true); - }), - ); - - it("calculates snapshot failure backoff and success reset delays", () => { - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000); - assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000); - }); - - it.effect("uses process snapshots from the resource monitor", () => - Effect.gen(function* () { - let snapshotCalls = 0; - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - processTable: Effect.sync(() => { - snapshotCalls += 1; - return [{ pid: 100, ppid: 9000, name: "ping.exe" }]; - }), - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && event.hasRunningSubprocess && event.label === "ping", - ), - ), - "1200 millis", - ); - expect(snapshotCalls).toBeGreaterThan(0); - }), - ); - - it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - processTable: Effect.succeed([ - { pid: 9000, ppid: 1, name: "zsh" }, - { pid: 100, ppid: 9000, name: "zsh" }, - { pid: 9001, ppid: 1, name: "zsh" }, - { pid: 200, ppid: 9001, name: "node" }, - { pid: 9002, ppid: 1, name: "zsh" }, - { pid: 300, ppid: 9002, name: "zsh" }, - { pid: 301, ppid: 300, name: "sleep" }, - { pid: 9003, ppid: 1, name: "zsh" }, - ]), - }).pipe(Effect.provide(layerWithHostPlatform("linux"))); - yield* manager.open(openInput({ terminalId: "idle" })); - yield* manager.open(openInput({ terminalId: "dev-server" })); - yield* manager.open(openInput({ terminalId: "subshell" })); - yield* manager.open(openInput({ threadId: "thread-2" })); - - yield* manager.closeIdle({ threadId: "thread-1" }); - - expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ - true, - false, - false, - false, - ]); - }), - ); - - it.effect("keeps terminals that get input or output while closeIdle checks them", () => - Effect.gen(function* () { - const ptyAdapter = new FakePtyAdapter(); - let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; - const { manager, getEvents } = yield* createManager(5, { - ptyAdapter, - subprocessPollIntervalMs: 60_000, - subprocessInspector: (pid) => - duringCheck(pid).pipe( - Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), - ), - }); - yield* manager.open(openInput({ terminalId: "typed" })); - yield* manager.open(openInput({ terminalId: "echoed" })); - const [typed, echoed] = ptyAdapter.processes; - duringCheck = (pid) => - pid === typed!.pid - ? manager - .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) - .pipe(Effect.orDie) - : Effect.gen(function* () { - echoed!.emitData("make build\r\n"); - yield* waitFor( - Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), - ); - }).pipe(Effect.orDie); - - yield* manager.closeIdle({ threadId: "thread-1" }); - - expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); - }), - ); - - it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => - Effect.gen(function* () { - const fallbackCalls: Array = []; - const processRunner: ProcessRunner.ProcessRunner["Service"] = { - run: () => - Clock.currentTimeMillis.pipe( - Effect.map((now) => { - fallbackCalls.push(now); - return { - stdout: " 100 9000 vim", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrInvalidUtf8: false, - stdoutInvalidUtf8: false, - stderrTruncated: false, - }; - }), - ), - }; - - const { manager, getEvents } = yield* createManager(5, { - subprocessPollIntervalMs: 20, - processTable: Effect.fail("sidecar unavailable").pipe( - Effect.mapError((cause) => cause as never), - ), - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.provide(layerWithHostPlatform("linux")), - ); - - yield* manager.open(openInput()); - yield* waitFor( - Effect.map(getEvents, (events) => - events.some( - (event) => - event.type === "activity" && - event.hasRunningSubprocess === true && - event.label === "vim", - ), - ), - "1200 millis", - ); - - yield* waitFor( - Effect.sync(() => fallbackCalls.length >= 4), - "2000 millis", - ); - const spanMs = fallbackCalls[3]! - fallbackCalls[0]!; - expect(spanMs).toBeGreaterThan(150); - }), - ); - - it.effect("coalesces live history writes while retaining the latest output on close", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const writes = yield* Queue.unbounded(); - const output = yield* Queue.unbounded(); - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fs, - writeFileString: (filePath, contents, options) => - fs - .writeFileString(filePath, contents, options) - .pipe( - Effect.tap(() => (contents.length > 0 ? Queue.offer(writes, contents) : Effect.void)), - ), - }); - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }).pipe( - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - yield* manager.open(openInput()); - const unsubscribe = yield* manager.subscribe((event) => - event.type === "output" ? Queue.offer(output, event.data).pipe(Effect.asVoid) : Effect.void, - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const process = ptyAdapter.processes[0]!; - - process.emitData("first"); - yield* Queue.take(output); - yield* TestClock.adjust("200 millis"); - expect(yield* Queue.size(writes)).toBe(0); - - process.emitData(" second"); - yield* Queue.take(output); - yield* TestClock.adjust("50 millis"); - expect(yield* Queue.take(writes)).toBe("first second"); - - process.emitData(" third"); - yield* Queue.take(output); - const close = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("250 millis"); - yield* Fiber.join(close); - expect((yield* manager.open(openInput())).history).toBe("first second third"); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("caps persisted history to configured line limit", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\nline2\nline3\nline4\n"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0); - expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]); - }), - ); - - it.effect("caps incrementally appended history without losing partial or empty lines", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(3); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("line1\n"); - process.emitData("\n"); - process.emitData("line3"); - process.emitData("-continued\nline4"); - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - expect(reopened.history).toBe("\nline3-continued\nline4"); - }), - ); - - it.effect("bounds persisted and attached history without truncating live output", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(5, { historyByteLimit: 10 }); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const writes = ["a".repeat(32), "😀\rEND"]; - const process = ptyAdapter.processes[0]!; - for (const text of writes) process.emitData(text); - yield* manager.close({ threadId: "thread-1" }); - expect(yield* readFileString(yield* historyLogPath(logsDir))).toBe("aa😀\rEND"); - - const reopened = yield* manager.open(openInput()); - const events = yield* Ref.get(attachEvents); - expect(events.filter((event) => event.type === "output").map((event) => event.data)).toEqual( - writes, - ); - const snapshot = events.filter((event) => event.type === "snapshot").at(-1)?.snapshot; - expect(snapshot?.history).toBe("aa😀\rEND"); - expect(snapshot?.sequence).toBe(reopened.sequence); - }), - ); - - it.effect.each(["current", "legacy"] as const)( - "reads only a Unicode-safe tail from oversized %s history", - (source) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - let sourcePath: string | undefined; - let closedReads = 0; - const readRequests: number[] = []; - const trackedFileSystem = FileSystem.FileSystem.of({ - ...fs, - readFileString: (candidate, encoding) => - candidate === sourcePath - ? Effect.die("History restoration must not read the whole file") - : fs.readFileString(candidate, encoding), - open: (candidate, options) => - Effect.gen(function* () { - if (candidate !== sourcePath) return yield* fs.open(candidate, options); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closedReads += 1; - }), - ); - const file = yield* fs.open(candidate, options); - return new Proxy(file, { - get(target, key) { - if (key === "read") { - return (buffer: Uint8Array) => { - readRequests.push(buffer.byteLength); - return target.read(buffer.subarray(0, 5)); - }; - } - return Reflect.get(target, key, target); - }, - }); - }), - }); - const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 15 }).pipe( - Effect.provideService(FileSystem.FileSystem, trackedFileSystem), - ); - const nextPath = yield* historyLogPath(logsDir); - sourcePath = source === "current" ? nextPath : path.join(logsDir, "thread-1.log"); - yield* fs.writeFileString(sourcePath, "old".repeat(32_768) + "😀\uFEFFnewest\ré"); - - const snapshot = yield* manager.open(openInput()); - expect(snapshot.history).toBe("\uFEFFnewest\ré"); - expect(readRequests).toEqual([15, 10, 5]); - expect(closedReads).toBe(1); - expect(Buffer.from(yield* fs.readFile(nextPath)).toString()).toBe("\uFEFFnewest\ré"); - if (source === "legacy") expect(yield* fs.exists(sourcePath)).toBe(false); - yield* manager.close({ threadId: "thread-1" }); - expect((yield* manager.open(openInput())).history).toBe("\uFEFFnewest\ré"); - }), - ); - - it.effect("retains plain Unicode and control characters around a split query", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]!; - const before = "café\t界🙂\u0007\u009c\u0085"; - const after = "e\u0301\r\n"; - - process.emitData(before); - process.emitData("\u001b[5"); - process.emitData(`n${after}`); - yield* manager.close({ threadId: "thread-1" }); - - expect((yield* manager.open(openInput())).history).toBe(before + after); - }), - ); - - it.effect("strips replay-unsafe terminal query and reply sequences from persisted history", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("prompt "); - process.emitData("\u001b[32mok\u001b[0m "); - process.emitData("\u001b]11;rgb:ffff/ffff/ffff\u0007"); - process.emitData("\u001b[1;1R"); - process.emitData("done\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - expect(reopened.history.startsWith("prompt \u001b[32mok\u001b[0m done\n")).toBe(true); - }), - ); - - it.effect("restores dead-process history without resetting a live terminal", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const wasm = yield* fs.readFile( - NodeURL.fileURLToPath( - new URL("../../../web/src/terminal/ghostty/vendor/ghostty-vt.wasm", import.meta.url), - ), - ); - const { instance } = yield* Effect.promise(() => - WebAssembly.instantiate(Uint8Array.from(wasm).buffer, { env: { log: () => {} } }), - ); - const memory = instance.exports.memory as { buffer: ArrayBuffer }; - const call = (name: string, ...args: number[]) => - (instance.exports[name] as (...args: number[]) => number)(...args); - const buffer = call("ghostty_wasm_alloc_u8_array", 16384); - new Uint8Array(memory.buffer, buffer, 16384).fill(0); - const view = () => new DataView(memory.buffer); - view().setUint16(buffer, 100, true); - view().setUint16(buffer + 2, 24, true); - view().setUint32(buffer + 4, 100, true); - expect(call("ghostty_terminal_new", 0, buffer + 8, buffer)).toBe(0); - const terminal = view().getUint32(buffer + 8, true); - expect(call("ghostty_key_encoder_new", 0, buffer + 12)).toBe(0); - expect(call("ghostty_key_event_new", 0, buffer + 16)).toBe(0); - const encoder = view().getUint32(buffer + 12, true); - const key = view().getUint32(buffer + 16, true); - call("ghostty_key_event_set_key", key, 22); // GhosttyKey C in the vendored ABI. - call("ghostty_key_event_set_unshifted_codepoint", key, 99); - new Uint8Array(memory.buffer, buffer + 96, 1).set([99]); - call("ghostty_key_event_set_utf8", key, buffer + 96, 1); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - call("ghostty_key_event_free", key); - call("ghostty_key_encoder_free", encoder); - call("ghostty_terminal_free", terminal); - call("ghostty_wasm_free_u8_array", buffer, 16384); - }), - ); - - // Replay the actual server snapshot in the shipped emulator, not a second mode parser. - const replay = (history: string) => { - call("ghostty_terminal_reset", terminal); - const bytes = new TextEncoder().encode(history); - for (let offset = 0; offset < bytes.length; offset += 16256) { - const chunk = bytes.subarray(offset, offset + 16256); - new Uint8Array(memory.buffer, buffer + 128, chunk.length).set(chunk); - call("ghostty_terminal_vt_write", terminal, buffer + 128, chunk.length); - } - const get = (name: string, field: number) => { - view().setUint32(buffer + 20, 0, true); - expect(call(name, terminal, field, buffer + 20)).toBe(0); - return view().getUint32(buffer + 20, true); - }; - const state = { - cursor: [get("ghostty_terminal_get", 3), get("ghostty_terminal_get", 4)], - screen: get("ghostty_terminal_get", 6), - visible: get("ghostty_terminal_get", 7), - kitty: get("ghostty_terminal_get", 8), - mouse: get("ghostty_terminal_get", 11), - modes: [1, 66, 67, 1004, 1006, 1035, 1036, 2004].map((mode) => - get("ghostty_terminal_mode_get", mode), - ), - }; - call("ghostty_key_encoder_setopt_from_terminal", encoder, terminal); - call("ghostty_key_event_set_action", key, 0); - call("ghostty_key_event_set_mods", key, 0); - expect( - call("ghostty_key_encoder_encode", encoder, key, buffer + 128, 16256, buffer + 80), - ).toBe(0); - const release = new TextDecoder().decode( - new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), - ); - call("ghostty_key_event_set_action", key, 1); - call("ghostty_key_event_set_mods", key, 4); - expect( - call("ghostty_key_encoder_encode", encoder, key, buffer + 128, 16256, buffer + 80), - ).toBe(0); - const modifiedPress = new TextDecoder().decode( - new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), - ); - view().setUint32(buffer + 32, 32, true); - expect(call("ghostty_terminal_select_all", terminal, buffer + 32)).toBe(0); - expect(call("ghostty_terminal_set", terminal, 21, buffer + 32)).toBe(0); - view().setUint32(buffer + 64, 16, true); - view().setUint8(buffer + 72, 1); - view().setUint8(buffer + 73, 1); - expect( - call( - "ghostty_terminal_selection_format_buf", - terminal, - buffer + 64, - buffer + 128, - 16256, - buffer + 80, - ), - ).toBe(0); - const text = new TextDecoder().decode( - new Uint8Array(memory.buffer, buffer + 128, view().getUint32(buffer + 80, true)), - ); - return { ...state, release, modifiedPress, text }; - }; - - const { manager, ptyAdapter, logsDir } = yield* createManager(100); - const logPath = yield* historyLogPath(logsDir); - const text = `${Array.from({ length: 30 }, (_, i) => `history ${i}\r\n`).join("")}prompt % `; - const dirty = `${text}\u001b[>7u\u001b[>0u\u001b[>7u\u001b[?1049h\u001b[7;18H\u001b[?47h\u001b[>4;2m\u001b[>7u\u001b[>0u\u001b[>7u\u001b[?25l\u001b[?1;66;67;1003;1004;1006;2004h\u001b[?1035;1036ldead app`; - const clean = replay(text); - expect(clean).toMatchObject({ screen: 0, visible: 1, kitty: 0, mouse: 0, release: "" }); - expect(clean.text).toContain("history 0"); - expect(replay(dirty)).toMatchObject({ screen: 1, visible: 0, kitty: 7, mouse: 1 }); - expect(replay(dirty).release).not.toBe(""); - yield* fs.writeFileString(logPath, dirty); - - const events: TerminalAttachStreamEvent[] = []; - const outputReceived = yield* Deferred.make(); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - events.push(event); - if (event.type === "output") yield* Deferred.succeed(outputReceived, undefined); - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - const first = yield* manager.open(openInput()); - expect(replay(first.history).cursor).toEqual(replay(dirty + "\u001b[?1049l").cursor); - expect(replay(first.history)).toEqual(clean); - expect(replay(first.history + "\u001b[ history.slice(history.indexOf("dead app")); - expect(replay(trimmed(first.history)).cursor).toEqual(replay(trimmed(dirty)).cursor); - expect(replay(first.history + "\u001b[?1049hnext app")).toEqual( - replay(text + "\u001b[?1049hnext app"), - ); - expect(events[0]).toMatchObject({ type: "snapshot", snapshot: { history: first.history } }); - expect(ptyAdapter.spawnInputs).toHaveLength(1); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + const before = "café\t界🙂\u0007\u009c\u0085"; + const after = "e\u0301\r\n"; - const freshOutput = "fresh shell\u001b[>7u\u001b[?1003;1004h"; - ptyAdapter.processes[0]!.emitData(freshOutput); - yield* Deferred.await(outputReceived); - const active = yield* manager.open(openInput()); - expect(active.history).toBe(first.history + freshOutput); - expect(replay(active.history)).toMatchObject({ kitty: 7, mouse: 1 }); - expect(ptyAdapter.spawnInputs).toHaveLength(1); - expect(events[1]).toMatchObject({ type: "output", data: freshOutput }); + process.emitData(before); + process.emitData("\u001b[5"); + process.emitData(`n${after}`); yield* manager.close({ threadId: "thread-1" }); - expect(yield* fs.readFileString(logPath)).toBe(active.history); - const restarted = yield* TerminalManager.makeWithOptions({ - logsDir, - historyLineLimit: 100, - ptyAdapter, - processKillGraceMs: 1, - }); - const second = yield* restarted.open(openInput()); - expect(replay(second.history)).toEqual(replay(text + "fresh shell")); - yield* restarted.close({ threadId: "thread-1" }); - expect(yield* fs.readFileString(logPath)).toBe(second.history); - const third = yield* restarted.open(openInput()); - expect(replay(third.history)).toEqual(replay(text + "fresh shell")); - expect(ptyAdapter.spawnInputs).toHaveLength(3); - - const cut = yield* createManager(100); - const mixed = `main\u001b[4;10H\u001b[?1049h${"\r".repeat(DEFAULT_MAX_TERMINAL_BUFFER_BYTES)}\u001b[7;18Hretained\u001b[?47hmore`; - yield* fs.writeFileString(yield* historyLogPath(cut.logsDir), mixed); - const snapshot = yield* cut.manager.open(openInput()); - const clientHistory = terminalOutputText( - applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { - type: "snapshot", - snapshot, - }).output, - ); - expect(replay(clientHistory).cursor).toEqual( - replay("\u001b[7;18Hretained\u001b[?47hmore\u001b[?47l").cursor, - ); - expect(replay(clientHistory)).toMatchObject({ screen: 0, kitty: 0, mouse: 0, release: "" }); - yield* cut.manager.close({ threadId: "thread-1" }); + expect((yield* manager.open(openInput())).history).toBe(before + after); }), ); @@ -2096,53 +810,6 @@ it.layer( }), ); - it.effect( - "preserves clear and style control sequences while dropping chunk-split query traffic", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before clear\n"); - process.emitData("\u001b[H\u001b[2J"); - process.emitData("prompt "); - process.emitData("\u001b]11;"); - process.emitData("rgb:ffff/ffff/ffff\u0007\u001b[1;1"); - process.emitData("R\u001b[36mdone\u001b[0m\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - expect( - reopened.history.startsWith( - "before clear\n\u001b[H\u001b[2Jprompt \u001b[36mdone\u001b[0m\n", - ), - ).toBe(true); - }), - ); - - it.effect("does not leak final bytes from ESC sequences with intermediate bytes", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("before "); - process.emitData("\u001b(B"); - process.emitData("after\n"); - - yield* manager.close({ threadId: "thread-1" }); - - const reopened = yield* manager.open(openInput()); - expect(reopened.history.startsWith("before \u001b(Bafter\n")).toBe(true); - }), - ); - it.effect( "preserves chunk-split ESC sequences with intermediate bytes without leaking final bytes", () => @@ -2164,32 +831,6 @@ it.layer( }), ); - it.effect("deletes history file when close(deleteHistory=true)", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - process.emitData("bye\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - expect( - yield* historyLogPath(logsDir).pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - it.effect.each([ { label: "subagent", @@ -2237,216 +878,6 @@ it.layer( }), ); - it.effect("closes all terminals for a thread when close omits terminalId", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - const defaultProcess = ptyAdapter.processes[0]; - const sidecarProcess = ptyAdapter.processes[1]; - expect(defaultProcess).toBeDefined(); - expect(sidecarProcess).toBeDefined(); - if (!defaultProcess || !sidecarProcess) return; - - defaultProcess.emitData("default\n"); - sidecarProcess.emitData("sidecar\n"); - const path = yield* Path.Path; - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - yield* waitFor( - multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - - yield* manager.close({ threadId: "thread-1", deleteHistory: true }); - - assert.equal(defaultProcess.killed, true); - assert.equal(sidecarProcess.killed, true); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "default").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - expect( - yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ).toBe(false); - }), - ); - - it.effect("escalates terminal shutdown to SIGKILL when process does not exit in time", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeFiber); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("skips SIGKILL when the process exits during the grace period", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10_000 }); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const sigterm = yield* Deferred.make(); - process.onKill = (signal) => { - if (signal === "SIGTERM") Deferred.doneUnsafe(sigterm, Effect.void); - }; - - yield* manager.close({ threadId: "thread-1" }); - yield* Deferred.await(sigterm); - process.emitExit({ exitCode: 0, signal: 15 }); - yield* TestClock.adjust("10 seconds"); - - assert.deepEqual(process.killSignals, ["SIGTERM"]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("does not retain a kill fiber that finishes before registration", () => - Effect.gen(function* () { - let managerState: - | SynchronizedRef.SynchronizedRef<{ - killFibers: Map>; - }> - | undefined; - const originalMake =
(value: A) => Effect.sync(() => SynchronizedRef.makeUnsafe(value)); - const makeSpy = vi.spyOn(SynchronizedRef, "make").mockImplementation((value) => - originalMake(value).pipe( - Effect.map((ref) => { - if (typeof value === "object" && value !== null && "killFibers" in value) { - managerState = ref as typeof managerState; - } - return ref; - }), - ), - ); - yield* Effect.addFinalizer(() => Effect.sync(() => makeSpy.mockRestore())); - for (const budget of [10, 15, 20, 25, 30, 40, 50, 75, 100, 150, 200, 500]) { - const { manager, ptyAdapter } = yield* createManager(); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]!; - process.onKill = () => process.emitExit({ exitCode: 0, signal: 15 }); - // Vary the caller's operation budget to cover child completion before - // and after the parent resumes its registration step. - yield* manager - .close({ threadId: "thread-1" }) - .pipe(Effect.provideService(References.MaxOpsBeforeYield, budget)); - yield* Effect.yieldNow; - expect(managerState).toBeDefined(); - yield* Fiber.awaitAll(SynchronizedRef.getUnsafe(managerState!).killFibers.values()); - expect(SynchronizedRef.getUnsafe(managerState!).killFibers.size, `budget ${budget}`).toBe( - 0, - ); - expect(process.killSignals).toEqual(["SIGTERM"]); - } - }), - ); - - it.effect("releases the exit listener when kill escalation is interrupted", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10_000, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const sigterm = yield* Deferred.make(); - process.onKill = (signal) => { - if (signal === "SIGTERM") Deferred.doneUnsafe(sigterm, Effect.void); - }; - yield* manager.close({ threadId: "thread-1" }); - yield* Deferred.await(sigterm); - assert.isAbove(process.exitListenerCount, 0); - - yield* Scope.close(scope, Exit.void); - - assert.equal(process.exitListenerCount, 0); - assert.deepEqual(process.killSignals, ["SIGTERM"]); - }).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("publishes closed events when terminals are explicitly closed", () => - Effect.gen(function* () { - const { manager, getEvents } = yield* createManager(); - yield* manager.open(openInput({ terminalId: "default" })); - yield* manager.open(openInput({ terminalId: "sidecar" })); - - yield* manager.close({ threadId: "thread-1" }); - - const closedEvents = (yield* getEvents).filter( - (event): event is Extract => event.type === "closed", - ); - expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); - }), - ); - - it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => - Effect.gen(function* () { - const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { - maxRetainedInactiveSessions: 1, - }); - - yield* manager.open(openInput({ threadId: "thread-1" })); - yield* manager.open(openInput({ threadId: "thread-2" })); - - const first = ptyAdapter.processes[0]; - const second = ptyAdapter.processes[1]; - expect(first).toBeDefined(); - expect(second).toBeDefined(); - if (!first || !second) return; - - first.emitData("first-history\n"); - second.emitData("second-history\n"); - const path = yield* Path.Path; - yield* waitFor( - historyLogPath(logsDir, "thread-1").pipe( - Effect.provideService(Path.Path, path), - Effect.flatMap(pathExists), - ), - ); - first.emitExit({ exitCode: 0, signal: 0 }); - yield* Effect.sleep(Duration.millis(5)); - second.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map( - getEvents, - (events) => events.filter((event) => event.type === "exited").length === 2, - ), - ); - - const reopenedSecond = yield* manager.open(openInput({ threadId: "thread-2" })); - const reopenedFirst = yield* manager.open(openInput({ threadId: "thread-1" })); - - assert.equal(reopenedFirst.history, "first-history\n"); - assert.equal(reopenedSecond.history, ""); - }), - ); - it.effect("migrates legacy transcript filenames to terminal-scoped history path on open", () => Effect.gen(function* () { const { manager, logsDir } = yield* createManager(); @@ -2501,27 +932,6 @@ it.layer( }), ); - it.effect("prefers PowerShell over ComSpec for Windows terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - ComSpec: "C:\\Windows\\System32\\cmd.exe", - PATH: "C:\\Windows\\System32", - SystemRoot: "C:\\Windows", - }, - }).pipe(Effect.provide(layerWithHostPlatform("win32"))); - - yield* manager.open(openInput()); - - expect(ptyAdapter.spawnInputs[0]).toEqual( - expect.objectContaining({ - shell: "pwsh.exe", - args: ["-NoLogo"], - }), - ); - }), - ); - it.effect("preserves Windows Path casing when appending managed ACP binaries", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2615,32 +1025,6 @@ it.layer( }), ); - it.effect.each(["linux", "darwin", "win32"] as const)( - "advertises truecolor before the PTY backend on %s without replacing explicit values", - (platform) => - Effect.gen(function* () { - for (const [parentColor, runtimeColor, expected] of [ - [undefined, undefined, "truecolor"], - ["", undefined, "truecolor"], - ["24bit", undefined, "24bit"], - ["24bit", "", ""], - [undefined, "", ""], - ["24bit", "custom", "custom"], - ] as const) { - const env = Object.freeze({ COLORTERM: parentColor }); - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/sh", - env, - }).pipe(Effect.provide(layerWithHostPlatform(platform))); - yield* manager.open( - openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }), - ); - expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected); - expect(env.COLORTERM).toBe(parentColor); - } - }), - ); - it.effect("activates a project virtual environment in new shells when enabled", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -2814,26 +1198,6 @@ it.layer( }), ); - it.effect("expands provider home paths passed to setup terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5); - - yield* manager.open({ - ...openInput(), - env: { - CODEX_HOME: "~/.codex-work", - CLAUDE_CONFIG_DIR: "~/.claude-work", - CUSTOM_ACCOUNT: "~/leave-this-value-alone", - }, - }); - - const environment = ptyAdapter.spawnInputs[0]?.env; - expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); - expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); - expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); - }), - ); - it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -2856,60 +1220,14 @@ it.layer( if (!spawnInput) return; expect(spawnInput.env.APPIMAGE).toBeUndefined(); - expect(spawnInput.env.APPDIR).toBeUndefined(); - expect(spawnInput.env.ARGV0).toBeUndefined(); - expect(spawnInput.env.OWD).toBeUndefined(); - expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); - expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); - expect(spawnInput.env.XDG_DATA_DIRS).toBe("/usr/local/share:/usr/share"); - expect(spawnInput.env.GSETTINGS_SCHEMA_DIR).toBeUndefined(); - expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); - }), - ); - - it.effect("leaves the environment untouched when not launched from an AppImage", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - env: { - PATH: "/usr/local/bin:/usr/bin:/bin", - LD_LIBRARY_PATH: "/home/user/.local/lib", - OWD: "/home/user/keep-this", - }, - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); - expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); - expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); - }), - ); - - it.effect("injects runtime env overrides into spawned terminals", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { env: { FORCE_COLOR: "3" } }); - yield* manager.open( - openInput({ - env: { - T3CODE_PROJECT_ROOT: "/repo", - T3CODE_WORKTREE_PATH: "/repo/worktree-a", - CUSTOM_FLAG: "1", - NO_COLOR: "1", - FORCE_COLOR: "0", - }, - }), - ); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - assert.equal(spawnInput.env.T3CODE_PROJECT_ROOT, "/repo"); - assert.equal(spawnInput.env.T3CODE_WORKTREE_PATH, "/repo/worktree-a"); - assert.equal(spawnInput.env.CUSTOM_FLAG, "1"); - assert.equal(spawnInput.env.NO_COLOR, "1"); - assert.equal(spawnInput.env.FORCE_COLOR, "0"); + expect(spawnInput.env.APPDIR).toBeUndefined(); + expect(spawnInput.env.ARGV0).toBeUndefined(); + expect(spawnInput.env.OWD).toBeUndefined(); + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + expect(spawnInput.env.XDG_DATA_DIRS).toBe("/usr/local/share:/usr/share"); + expect(spawnInput.env.GSETTINGS_SCHEMA_DIR).toBeUndefined(); + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); }), ); @@ -2961,46 +1279,6 @@ it.layer( }), ); - it.effect("preserves the settings failure when provider environment resolution fails", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const providerInstanceId = ProviderInstanceId.make("codex_work"); - const settingsCause = new Error("secret store read failed"); - const settingsError = new ServerSettingsError({ - settingsPath: "/test/settings.json", - operation: "read-secret", - providerInstanceId, - environmentVariable: "OPENROUTER_API_KEY", - cause: settingsCause, - }); - const serverSettings = ServerSettings.ServerSettingsService.of({ - start: Effect.void, - ready: Effect.void, - getSettings: Effect.fail(settingsError), - updateSettings: () => Effect.fail(settingsError), - updateProviderInstance: () => Effect.fail(settingsError), - withSettingsSnapshot: () => Effect.fail(settingsError), - streamChanges: Stream.empty, - subscribeChanges: Effect.succeed(Stream.empty), - }); - - const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: providerInstanceId, - env: undefined, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TerminalProviderEnvironmentError", - providerInstanceId, - }); - expect(error.cause).toBe(settingsError); - expect(error.message).not.toContain(settingsError.message); - expect(error.message).not.toContain("OPENROUTER_API_KEY"); - }), - ); - it.effect.each([ { name: "Codex home", @@ -3050,93 +1328,6 @@ it.layer( ), ); - it.effect("resolves the Codex default slot", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex", - env: undefined, - }); - - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); - }).pipe( - Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath: "~/.codex-default" }, - }, - }, - }), - ), - ), - ); - - it.effect("resolves the Claude default slot", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "claudeAgent", - env: undefined, - }); - - expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); - }).pipe( - Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ - providerInstances: { - [ProviderInstanceId.make("claudeAgent")]: { - driver: ProviderDriverKind.make("claudeAgent"), - config: { homePath: "~/.claude-default" }, - }, - }, - }), - ), - ), - ); - - it.effect("resolves an empty Codex default slot with default config", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex", - env: { CODEX_HOME: "/inherited/codex-home" }, - }); - - expect(environment.CODEX_HOME).toBe("/inherited/codex-home"); - }).pipe( - Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), - ), - ); - - it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId: "codex_unknown", - env: undefined, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "TerminalProviderInstanceNotFoundError", - providerInstanceId: "codex_unknown", - }); - }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), - ); - it.effect("restarts a running terminal when the resolved provider environment changes", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); @@ -3265,148 +1456,6 @@ it.layer( }), ); - it.effect("fails closed when attaching would create a missing provider terminal", () => - Effect.gen(function* () { - const providerInstanceId = ProviderInstanceId.make("deleted_instance"); - const { manager, ptyAdapter } = yield* createManager(5, { - resolveProviderInstanceEnvironment: (requestedId) => - Effect.fail( - new TerminalProviderInstanceNotFoundError({ - providerInstanceId: ProviderInstanceId.make(requestedId), - }), - ), - }); - - const error = yield* manager - .attachStream(openInput({ providerInstanceId }), () => Effect.void) - .pipe(Effect.flip); - - assert.deepStrictEqual( - error, - new TerminalProviderInstanceNotFoundError({ providerInstanceId }), - ); - expect(ptyAdapter.spawnInputs).toHaveLength(0); - }), - ); - - it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => - Effect.gen(function* () { - if ((yield* HostProcess.Platform) === "win32") return; - const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/bin/zsh", - }); - yield* manager.open(openInput()); - const spawnInput = ptyAdapter.spawnInputs[0]; - expect(spawnInput).toBeDefined(); - if (!spawnInput) return; - - expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); - }), - ); - - it.effect("bridges PTY callbacks back into Effect-managed event streaming", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from callback\n"); - - yield* waitFor( - Effect.map(getEvents, (events) => - events.some((event) => event.type === "output" && event.data === "hello from callback\n"), - ), - "1200 millis", - ); - }), - ); - - it.effect("pushes PTY callbacks to direct event subscribers", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const subscriberEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribe((event) => - Ref.update(subscriberEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("hello from subscriber\n"); - - yield* waitFor( - Effect.map(Ref.get(subscriberEvents), (events) => - events.some( - (event) => event.type === "output" && event.data === "hello from subscriber\n", - ), - ), - "1200 millis", - ); - }), - ); - - it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => - Effect.gen(function* () { - const { manager } = yield* createManager(); - yield* manager.open(openInput({ threadId: "existing-thread" })); - - const metadataEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.subscribeMetadata((event) => - Ref.update(metadataEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const initialEvents = yield* Ref.get(metadataEvents); - expect(initialEvents[0]).toMatchObject({ - type: "snapshot", - terminals: [ - { - threadId: "existing-thread", - terminalId: DEFAULT_TERMINAL_ID, - }, - ], - }); - - yield* manager.open(openInput({ threadId: "new-thread" })); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "upsert" && - event.terminal.threadId === "new-thread" && - event.terminal.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - - yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); - - yield* waitFor( - Effect.map(Ref.get(metadataEvents), (events) => - events.some( - (event) => - event.type === "remove" && - event.threadId === "new-thread" && - event.terminalId === DEFAULT_TERMINAL_ID, - ), - ), - "1200 millis", - ); - }), - ); - it.effect("removes terminal metadata subscriptions when initial delivery fails", () => Effect.gen(function* () { const { manager } = yield* createManager(); @@ -3428,47 +1477,6 @@ it.layer( }), ); - it.effect( - "streams attach snapshots followed by live events without duplicate start snapshots", - () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { - type: "snapshot", - snapshot: { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - }, - ]); - - process.emitData("hello from attach\n"); - - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "hello from attach\n"), - ), - "1200 millis", - ); - - const events = yield* Ref.get(attachEvents); - expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); - }), - ); - it.effect("finishes shell startup when its creating attachment is cancelled", () => Effect.gen(function* () { const ptyAdapter = new GatedPtyAdapter(); @@ -3714,113 +1722,4 @@ it.layer( expect(missingEvents).toEqual([]); }), ); - - it.effect("buffers attach output delivered during the initial snapshot callback", () => - Effect.gen(function* () { - const { manager, ptyAdapter } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - yield* manager.open(openInput()); - - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream(openInput(), (event) => - Effect.gen(function* () { - yield* Ref.update(attachEvents, (events) => [...events, event]); - if (event.type === "snapshot") { - yield* Effect.sync(() => process.emitData("during snapshot\n")); - yield* Effect.yieldNow; - } - }), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* waitFor( - Effect.map(Ref.get(attachEvents), (events) => - events.some((event) => event.type === "output" && event.data === "during snapshot\n"), - ), - "1200 millis", - ); - - expect(yield* Ref.get(attachEvents)).toMatchObject([ - { type: "snapshot" }, - { type: "output", data: "during snapshot\n" }, - ]); - }), - ); - - it.effect("preserves queued PTY output ordering through exit callbacks", () => - Effect.gen(function* () { - const { manager, ptyAdapter, getEvents } = yield* createManager(5, { - ptyAdapter: new FakePtyAdapter("async"), - }); - - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - process.emitData("first\n"); - process.emitData("second\n"); - process.emitExit({ exitCode: 0, signal: 0 }); - - yield* waitFor( - Effect.map(getEvents, (events) => { - const relevant = events.filter( - (event) => event.type === "output" || event.type === "exited", - ); - return relevant.length >= 3; - }), - "1200 millis", - ); - - const relevant = (yield* getEvents).filter( - (event) => event.type === "output" || event.type === "exited", - ); - expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), - expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), - ]); - - const attachEvents = yield* Ref.make>([]); - const unsubscribe = yield* manager.attachStream( - { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - }, - (event) => Ref.update(attachEvents, (events) => [...events, event]), - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); - expect(snapshot).toBeDefined(); - if (!snapshot || snapshot.type !== "snapshot") return; - expect(snapshot.snapshot.sequence).toBe(4); - }), - ); - - it.effect("scoped runtime shutdown stops active terminals cleanly", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - const { manager, ptyAdapter } = yield* createManager(5, { - processKillGraceMs: 10, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* manager.open(openInput()); - const process = ptyAdapter.processes[0]; - expect(process).toBeDefined(); - if (!process) return; - - const closeScope = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust("10 millis"); - yield* Fiber.join(closeScope); - - assert.equal(process.killSignals[0], "SIGTERM"); - expect(process.killSignals).toContain("SIGKILL"); - }).pipe(Effect.provide(TestClock.layer())), - ); }); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9c9303c053a6..a42eb9b9b7e9 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -666,10 +666,7 @@ interface TerminalProcessTableSnapshot { readonly commandById: ReadonlyMap; } -export function subprocessSnapshotPollDelayMs( - pollIntervalMs: number, - failureCount: number, -): number { +function subprocessSnapshotPollDelayMs(pollIntervalMs: number, failureCount: number): number { return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); } diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 79f73c2e9060..66704396e9e7 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -4,14 +4,11 @@ import * as NodeNet from "node:net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Scheduler from "effect/Scheduler"; -import { expect, vi } from "vite-plus/test"; +import { vi } from "vite-plus/test"; import * as NodePtyAdapter from "./NodePtyAdapter.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; @@ -160,125 +157,6 @@ it.effect("reports an incompatible Windows readiness API instead of hanging", () }).pipe(Effect.provide(layerTest)), ); -it.effect.each(["win32", "linux", "darwin"] as const)( - "terminates through node-pty using %s semantics", - (platform) => - Effect.gen(function* () { - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "test-shell", - cwd: ".", - cols: 80, - rows: 24, - env: {}, - }); - const nativeProcess = spawn.mock.results.at(-1)!.value; - nativeProcess.kill.mockImplementation((signal?: string) => { - if (platform === "win32" && signal) { - throw new Error("Signals not supported on windows."); - } - }); - - process.kill("SIGTERM"); - process.kill("SIGKILL"); - process.kill(); - - assert.deepEqual( - nativeProcess.kill.mock.calls, - platform === "win32" - ? [[undefined], [undefined], [undefined]] - : [["SIGTERM"], ["SIGKILL"], [undefined]], - ); - }).pipe(Effect.provide(layerTestFor(platform))), -); - -it.effect("spawns through the public adapter with the provided host references", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - const process = yield* adapter.spawn({ - shell: "powershell.exe", - args: ["-NoLogo"], - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: {}, - }); - - assert.equal(process.pid, 42); - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - ["-NoLogo"], - { - cwd: "C:\\workspace", - cols: 120, - rows: 40, - env: { TERM: "xterm-256color" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("preserves a caller-provided TERM in the spawn env on win32", () => - Effect.gen(function* () { - spawn.mockClear(); - const adapter = yield* PtyAdapter.PtyAdapter; - yield* adapter.spawn({ - shell: "powershell.exe", - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - }); - - assert.equal(spawn.mock.calls.length, 1); - assert.deepEqual(spawn.mock.calls[0], [ - "powershell.exe", - [], - { - cwd: "C:\\workspace", - cols: 80, - rows: 24, - env: { TERM: "xterm-direct" }, - name: "xterm-256color", - }, - ]); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("reports native module load failures as structured startup defects", () => - Effect.gen(function* () { - const cause = new Error("native binding could not be loaded"); - const exit = yield* NodePtyAdapter.make().pipe( - Effect.provideService(NodePtyAdapter.NodePtyModuleLoaderRef, () => Promise.reject(cause)), - Effect.exit, - ); - - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - assert.isTrue(Cause.hasDies(exit.cause)); - const error = Cause.squash(exit.cause); - assert.instanceOf(error, NodePtyAdapter.NodePtyModuleLoadError); - assert.deepInclude(error, { - _tag: "NodePtyModuleLoadError", - platform: "win32", - architecture: "x64", - }); - assert.equal(error.message, "Failed to load node-pty for win32-x64."); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.succeed(HostProcess.Platform, "win32"), - Layer.succeed(HostProcess.Architecture, "x64"), - ), - ), - ), -); - it.effect.each([2048, 8])( "preserves an exit during readiness handoff with scheduler budget %s", (budget) => @@ -319,47 +197,3 @@ it.effect("replays an exit to late subscribers and respects unsubscription", () assert.equal(nativeProcess.events.listenerCount("exit"), 0); }).pipe(Effect.provide(layerTest)), ); - -it.effect.each(["spawn", "interrupt"] as const)( - "logs cleanup failures without replacing %s", - (failure) => - Effect.gen(function* () { - const { nativeProcess, subscribed } = preparePendingProcess(); - const killError = new Error("native kill failed"); - nativeProcess._agent.kill.mockImplementation(() => { - throw killError; - }); - const messages: unknown[] = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - const adapter = yield* PtyAdapter.PtyAdapter; - const fiber = yield* adapter - .spawn(spawnInput) - .pipe( - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), - Effect.forkChild, - ); - yield* subscribed; - const spawnError = new Error("pipe failed"); - if (failure === "interrupt") yield* Fiber.interrupt(fiber); - else nativeProcess._socket.emit("error", spawnError); - const exit = yield* Fiber.await(fiber); - assert.isTrue(Exit.isFailure(exit)); - if (Exit.isFailure(exit)) { - if (failure === "interrupt") assert.isTrue(Cause.hasInterrupts(exit.cause)); - else { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, PtyAdapter.PtySpawnError); - assert.equal(error.cause, spawnError); - } - } - assert.equal(messages.length, 1); - expect(messages[0]).toMatchObject([ - "failed to cancel Windows terminal startup", - { terminalPid: 0, cause: { cause: killError } }, - ]); - assert.equal(nativeProcess.events.listenerCount("exit"), 0); - assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); - }).pipe(Effect.provide(layerTest)), -); diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index e9b1fe5d4b91..047c24ae8fc3 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -225,7 +225,7 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } } -export const make = Effect.fn("NodePtyAdapter.make")(function* () { +const make = Effect.fn("NodePtyAdapter.make")(function* () { const loadNodePtyModule = yield* NodePtyModuleLoaderRef; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/terminal/nodePtyConsoleList.test.ts b/apps/server/src/terminal/nodePtyConsoleList.test.ts index b412f3c8502a..be538491877c 100644 --- a/apps/server/src/terminal/nodePtyConsoleList.test.ts +++ b/apps/server/src/terminal/nodePtyConsoleList.test.ts @@ -61,6 +61,7 @@ describe("node-pty console process list helper", () => { expect(list).toEqual([SHELL_PID]); }); + // regression: #18519 Windows terminal kill waited on timeouts it("falls back to the shell PID when the helper fails to spawn", async () => { const list = await getConsoleProcessList((helper) => { helper.emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" })); diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts index a8139563ff3b..19065fae6a9e 100644 --- a/apps/server/src/testUtils/gitConfig.setup.ts +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs once at test setup, outside any Effect runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + // Git for Windows installs with core.autocrlf=true, which checks committed LF // files out as CRLF and breaks every byte-exact content assertion; a signing // key or a non-default init branch on the developer's machine breaks fixtures @@ -18,3 +24,24 @@ entries.forEach(([key, value], index) => { process.env[`GIT_CONFIG_KEY_${existing + index}`] = key; process.env[`GIT_CONFIG_VALUE_${existing + index}`] = value; }); + +// Without Homebrew git, macOS resolves `git` to /usr/bin/git, an xcrun shim that costs +// about 8 ms per spawn against 3 ms for the real binary. Git-heavy files such as +// GitVcsDriverCore.test.ts and GitManager.test.ts run twice as fast with the real one. +if (HostProcess.Platform.defaultValue() === "darwin") { + const gitOnPath = (process.env.PATH ?? "") + .split(NodePath.delimiter) + .map((dir) => NodePath.join(dir, "git")) + .find((candidate) => NodeFS.existsSync(candidate)); + if (gitOnPath === "/usr/bin/git") { + try { + const realGit = NodeChildProcess.execFileSync("xcrun", ["-f", "git"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (NodePath.isAbsolute(realGit)) { + process.env.PATH = `${NodePath.dirname(realGit)}${NodePath.delimiter}${process.env.PATH}`; + } + } catch {} + } +} diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index 54e1203030ad..d0f1fafc6968 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -1,429 +1,216 @@ { "integration/transferBudgetV2.integration.test.ts": 3.1, - "src/assets/AssetAccess.test.ts": 1.7, - "src/assets/AttachmentUpload.test.ts": 0.4, - "src/assets/NativeAppIconResolver.test.ts": 1.0, - "src/assets/icns.test.ts": 0.1, - "src/attachmentStore.test.ts": 0.3, - "src/auth/EnvironmentAuth.test.ts": 0.9, + "src/assets/AssetAccess.test.ts": 1.3, + "src/auth/EnvironmentAuth.test.ts": 0.8, "src/auth/EnvironmentAuthAdmin.test.ts": 1.6, - "src/auth/EnvironmentAuthPolicy.test.ts": 0.4, "src/auth/McpOAuth.test.ts": 1.5, - "src/auth/PairingGrantStore.test.ts": 0.6, - "src/auth/RpcAuthorization.test.ts": 0.4, - "src/auth/ServerSecretStore.test.ts": 0.4, - "src/auth/SessionStore.test.ts": 1.4, + "src/auth/SessionStore.test.ts": 0.9, "src/auth/dpop.test.ts": 0.5, "src/auth/http.test.ts": 0.5, "src/auth/replayMarkers.test.ts": 3.7, - "src/auth/utils.test.ts": 0.1, - "src/background/BackgroundPolicy.test.ts": 0.3, - "src/background/HostPowerMonitor.test.ts": 0.4, - "src/bootstrap.test.ts": 0.4, - "src/checkpointing/CheckpointDiffQuery.test.ts": 1.3, - "src/checkpointing/CheckpointStore.test.ts": 1.7, - "src/checkpointing/Diffs.test.ts": 0.1, - "src/claudeModelOptions.test.ts": 0.4, + "src/checkpointing/CheckpointDiffQuery.test.ts": 1.1, + "src/checkpointing/CheckpointStore.test.ts": 0.9, "src/cli/app.test.ts": 2.4, "src/cli/config.test.ts": 0.6, - "src/cli/connect.test.ts": 0.4, - "src/cli/invocation.test.ts": 0.2, - "src/cli/pair.test.ts": 3.2, - "src/cli/project.test.ts": 3.1, + "src/cli/pair.test.ts": 2.4, + "src/cli/project.test.ts": 2.4, "src/cli/service.test.ts": 0.5, - "src/cli/sshHelper.test.ts": 2.4, "src/cli/theme.test.ts": 2.1, "src/cli/trace.test.ts": 1.2, "src/cli/triage.test.ts": 0.5, - "src/cli/triagePrompt.test.ts": 0.1, "src/cli/uninstall.test.ts": 0.5, "src/cli/update.test.ts": 1.7, - "src/cli/updateProgress.test.ts": 0.1, - "src/cliAuthFormat.test.ts": 0.2, - "src/cloud/CliState.test.ts": 0.3, "src/cloud/CliTokenManager.test.ts": 0.7, - "src/cloud/CloudLink.lifecycle.test.ts": 1.9, + "src/cloud/CloudLink.lifecycle.test.ts": 0.9, "src/cloud/CloudLink.test.ts": 1.1, - "src/cloud/ManagedEndpointRuntime.test.ts": 0.4, - "src/cloud/bootService.test.ts": 0.6, - "src/cloud/environmentKeys.test.ts": 0.4, + "src/cloud/bootService.test.ts": 0.5, "src/cloud/http.test.ts": 1.2, - "src/cloud/linkChecks.test.ts": 0.2, "src/cloud/managedTunnelStartup.test.ts": 0.5, - "src/cloud/pinnedRuntime.test.ts": 0.4, - "src/cloud/publicConfig.test.ts": 0.2, - "src/cloud/relayResponse.test.ts": 0.3, "src/cloud/selfUpdate.test.ts": 0.6, - "src/cloud/serviceLauncherClient.test.ts": 0.2, - "src/cloud/servicePreflight.test.ts": 0.1, - "src/cloud/traceRelayRequest.test.ts": 0.2, "src/codexModelOptions.test.ts": 0.6, - "src/compileCache.test.ts": 0.3, - "src/desktopUpdate/DesktopAppUpdate.test.ts": 0.3, - "src/device/AgentDeviceTarget.test.ts": 0.3, "src/device/DeviceActions.test.ts": 0.7, "src/device/DeviceHubProxy.stream.test.ts": 0.6, - "src/device/DeviceHubProxy.test.ts": 0.4, "src/device/DeviceMultiHost.test.ts": 0.5, "src/device/DeviceService.test.ts": 0.5, - "src/device/DeviceToolchain.test.ts": 0.4, - "src/device/LocalDeviceHost.test.ts": 0.4, "src/device/SshDeviceHost.test.ts": 0.5, - "src/device/deviceToolMaintenance.test.ts": 1.7, - "src/device/localSshDeviceHost.test.ts": 0.2, - "src/device/sshDeviceScript.test.ts": 3.3, - "src/diagnostics/ProcessDiagnostics.test.ts": 0.3, - "src/diagnostics/ProcessResourceMonitor.test.ts": 0.4, - "src/diagnostics/TraceDiagnostics.test.ts": 0.4, - "src/entrypoint.test.ts": 0.1, - "src/environment/DirectEndpoints.test.ts": 0.4, - "src/environment/RemoteOpenTargets.test.ts": 0.4, + "src/device/deviceToolMaintenance.test.ts": 1.3, "src/environment/ServerEnvironment.test.ts": 0.7, - "src/environment/ServerEnvironmentLabel.test.ts": 0.3, - "src/environment/ServerEnvironmentMachine.test.ts": 0.3, - "src/environmentTheme.test.ts": 0.6, - "src/git/GitManager.test.ts": 46.5, + "src/git/GitManager.test.ts": 13.7, "src/git/GitWorkflowService.test.ts": 2.7, - "src/git/detachStackFrame.memory.test.ts": 0.8, "src/git/linkCreatedPullRequest.test.ts": 1.2, - "src/htmlRender/HtmlRender.test.ts": 0.4, - "src/htmlRender/publicProxy.test.ts": 0.2, - "src/http.test.ts": 2.0, - "src/httpResponseErrorGuard.test.ts": 0.2, - "src/imageMime.test.ts": 0.1, - "src/keybindings.test.ts": 0.4, - "src/mcp/AcpMcpStdioBridge.test.ts": 0.3, + "src/http.test.ts": 2, "src/mcp/McpDeviceToolkit.test.ts": 1.5, - "src/mcp/McpHttpServer.test.ts": 2.2, - "src/mcp/McpInvocationContext.test.ts": 0.3, + "src/mcp/McpHttpServer.test.ts": 1.3, "src/mcp/McpSessionRegistry.test.ts": 1.1, "src/mcp/McpToolAccess.race.test.ts": 1.4, "src/mcp/McpToolAccess.test.ts": 1.2, "src/mcp/OrchestratorMcpService.activity.test.ts": 1.1, "src/mcp/OrchestratorMcpService.test.ts": 1.2, - "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 14.4, + "src/mcp/OrchestratorMcpToolkit.integration.test.ts": 1.2, "src/mcp/PreviewAutomationBroker.test.ts": 0.5, - "src/mcp/TaskCancelNativeSubagent.integration.test.ts": 1.4, - "src/mcp/ThreadMetadataMcpService.test.ts": 1.2, - "src/mcp/WorktreeMcpService.test.ts": 1.2, + "src/mcp/TaskCancelNativeSubagent.integration.test.ts": 1, + "src/mcp/WorktreeMcpService.test.ts": 0.9, "src/mcp/toolkits/core.test.ts": 1.7, "src/mcp/toolkits/device/handlers.test.ts": 1.4, "src/mcp/toolkits/environment/handlers.test.ts": 1.1, - "src/mcp/toolkits/orchestrator/tools.test.ts": 1.1, "src/mcp/toolkits/preview/handlers.test.ts": 1.4, "src/mcp/toolkits/preview/tools.test.ts": 3.6, "src/mcp/toolkits/previewControls/handlers.test.ts": 1.6, "src/mcp/toolkits/project/handlers.test.ts": 1.3, "src/mcp/toolkits/pullRequests/handlers.test.ts": 1.3, - "src/mcp/toolkits/worktree/registration.test.ts": 1.5, - "src/mcpApps/McpAppModelContext.test.ts": 1.0, + "src/mcpApps/McpAppModelContext.test.ts": 1, "src/mcpApps/McpAppRequests.test.ts": 1.3, "src/observability/DefectReporter.test.ts": 1.2, - "src/observability/EventLoopMonitor.test.ts": 0.3, - "src/observability/HeapSnapshot.test.ts": 0.4, - "src/observability/Metrics.test.ts": 1.0, "src/observability/OtlpEndpointCheck.test.ts": 0.5, - "src/observability/RpcInstrumentation.test.ts": 0.3, - "src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts": 1.3, - "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 55.1, - "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 2.6, - "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.3, - "src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts": 9.5, + "src/orchestration-v2/Adapters/AcpAdapterV2.test.ts": 25.5, + "src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts": 1.3, + "src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts": 1.1, "src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts": 0.6, - "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 7.2, - "src/orchestration-v2/Adapters/GrokAdapterV2.test.ts": 0.5, - "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 4.8, - "src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts": 0.2, - "src/orchestration-v2/AttachmentClaims.test.ts": 0.4, - "src/orchestration-v2/BackgroundWorkStop.integration.test.ts": 5.7, + "src/orchestration-v2/Adapters/CodexAdapterV2.test.ts": 4.5, + "src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts": 2.8, + "src/orchestration-v2/BackgroundWorkStop.integration.test.ts": 4.3, "src/orchestration-v2/CheckpointCaptureService.test.ts": 0.5, "src/orchestration-v2/CheckpointRestoreSafety.test.ts": 0.6, "src/orchestration-v2/CheckpointRollbackService.test.ts": 0.6, "src/orchestration-v2/CheckpointScopeOwnership.test.ts": 2.6, - "src/orchestration-v2/CheckpointService.test.ts": 0.4, - "src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts": 2.1, + "src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts": 1.6, "src/orchestration-v2/CommandPolicy.test.ts": 0.6, - "src/orchestration-v2/ContextHandoffBudget.test.ts": 0.4, - "src/orchestration-v2/ContextHandoffService.test.ts": 0.3, - "src/orchestration-v2/CursorOrchestratorV2.live.test.ts": 2.0, "src/orchestration-v2/DelegatedCompletionDelivery.test.ts": 2.1, "src/orchestration-v2/DispatchModeLimit.test.ts": 1.1, "src/orchestration-v2/EffectWorker.test.ts": 1.2, - "src/orchestration-v2/FoundationPersistence.test.ts": 4.7, - "src/orchestration-v2/GrokOrchestratorV2.live.test.ts": 1.2, - "src/orchestration-v2/LiveStreamBudget.test.ts": 0.2, - "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 6.3, - "src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts": 1.7, + "src/orchestration-v2/FoundationPersistence.test.ts": 3.2, + "src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts": 4, "src/orchestration-v2/Orchestrator.control-reads.test.ts": 1.4, "src/orchestration-v2/Orchestrator.migration.test.ts": 1.5, - "src/orchestration-v2/ProjectCommands.test.ts": 0.3, "src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts": 1.4, - "src/orchestration-v2/ProjectStore.test.ts": 0.4, "src/orchestration-v2/ProjectionControlReads.test.ts": 5.3, "src/orchestration-v2/ProjectionRecovery.test.ts": 1.7, "src/orchestration-v2/ProjectionSettlement.test.ts": 1.3, - "src/orchestration-v2/ProjectionStore.test.ts": 2.6, - "src/orchestration-v2/ProviderAdapterRegistry.test.ts": 0.4, + "src/orchestration-v2/ProjectionStore.test.ts": 1.5, "src/orchestration-v2/ProviderContinuationService.test.ts": 1.2, - "src/orchestration-v2/ProviderEventIngestor.test.ts": 4.6, - "src/orchestration-v2/ProviderRuntimeRecoveryPerformance.test.ts": 2.5, + "src/orchestration-v2/ProviderEventIngestor.test.ts": 2.1, "src/orchestration-v2/ProviderRuntimeRecoveryService.regression.test.ts": 1.4, "src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts": 1.2, - "src/orchestration-v2/ProviderSelectionTransition.test.ts": 0.4, - "src/orchestration-v2/ProviderSessionManager.test.ts": 2.6, + "src/orchestration-v2/ProviderSessionManager.test.ts": 1.8, "src/orchestration-v2/ProviderSessionTransitionPolicy.test.ts": 0.5, "src/orchestration-v2/ProviderSwitchService.test.ts": 0.6, "src/orchestration-v2/ProviderTurnControlService.test.ts": 1.3, - "src/orchestration-v2/ProviderTurnStartService.memory.test.ts": 8.0, "src/orchestration-v2/ProviderTurnStartService.test.ts": 1.4, "src/orchestration-v2/ProviderTurnTokenUsage.test.ts": 0.6, "src/orchestration-v2/PullRequestSyncReactor.test.ts": 2.3, - "src/orchestration-v2/QueuedRunOrder.test.ts": 0.1, - "src/orchestration-v2/RestartBackgroundNote.test.ts": 0.3, "src/orchestration-v2/RestartContinuation.test.ts": 1.5, "src/orchestration-v2/RunCompletionReads.test.ts": 0.5, "src/orchestration-v2/RunExecutionService.test.ts": 1.4, "src/orchestration-v2/RunFinalizationService.test.ts": 1.1, - "src/orchestration-v2/RuntimePolicy.test.ts": 0.3, "src/orchestration-v2/RuntimeRequestService.test.ts": 0.6, - "src/orchestration-v2/SelectionRestart.integration.test.ts": 3.7, + "src/orchestration-v2/SelectionRestart.integration.test.ts": 2.4, "src/orchestration-v2/ShellStream.test.ts": 0.5, - "src/orchestration-v2/SteeringCompletion.integration.test.ts": 5.5, - "src/orchestration-v2/SubagentProjection.test.ts": 0.4, + "src/orchestration-v2/SteeringCompletion.integration.test.ts": 4.5, "src/orchestration-v2/ThreadDeletion.test.ts": 0.5, - "src/orchestration-v2/ThreadFind.test.ts": 1.6, + "src/orchestration-v2/ThreadFind.test.ts": 1, "src/orchestration-v2/ThreadFork.execution.test.ts": 1.4, - "src/orchestration-v2/ThreadForkService.test.ts": 0.4, - "src/orchestration-v2/ThreadLaunchService.test.ts": 4.5, - "src/orchestration-v2/ThreadLifecycleService.test.ts": 1.9, - "src/orchestration-v2/ThreadLiveEventCoalescer.test.ts": 0.3, - "src/orchestration-v2/ThreadManagementService.test.ts": 2.0, + "src/orchestration-v2/ThreadLaunchService.test.ts": 2.1, + "src/orchestration-v2/ThreadManagementService.test.ts": 0.7, "src/orchestration-v2/ThreadMessageIntake.test.ts": 1.1, "src/orchestration-v2/ThreadPullRequestService.test.ts": 1.2, "src/orchestration-v2/ThreadSearch.test.ts": 0.6, - "src/orchestration-v2/ThreadSettlementService.test.ts": 2.0, - "src/orchestration-v2/ThreadStop.test.ts": 2.5, - "src/orchestration-v2/ThreadStream.test.ts": 0.3, - "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 1.8, - "src/orchestration-v2/ThreadTransportPerformance.test.ts": 0.4, + "src/orchestration-v2/ThreadSettlementService.test.ts": 2, + "src/orchestration-v2/ThreadStop.test.ts": 2.1, + "src/orchestration-v2/ThreadTitleRegenerationService.test.ts": 1.2, "src/orchestration-v2/TurnItemPositionStore.test.ts": 0.5, "src/orchestration-v2/TurnStartReads.test.ts": 0.6, - "src/orchestration-v2/UserFacingErrors.test.ts": 0.4, - "src/orchestration-v2/V1ImportBoundary.test.ts": 0.3, - "src/orchestration-v2/WireProjection.test.ts": 0.4, - "src/orchestration-v2/assistantStreaming.test.ts": 0.3, "src/orchestration-v2/boundedSnapshotTransport.test.ts": 2.2, - "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 1.8, + "src/orchestration-v2/legacy/LegacyV1Cutover.integration.test.ts": 1.4, "src/orchestration-v2/legacy/LegacyV1ThreadImporter.test.ts": 0.5, - "src/orchestration-v2/pullRequestWatch.test.ts": 0.4, - "src/orchestration-v2/runtimeLayer.test.ts": 3.1, - "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 2.7, - "src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts": 1.7, - "src/orchestration-v2/testkit/OrchestratorReplayFixtures.contract.test.ts": 1.3, - "src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts": 60.3, - "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 2.7, - "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 2.4, - "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 27.3, - "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 4.3, - "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 6.6, - "src/orchestration-v2/threadHistoryPaging.test.ts": 0.3, - "src/orchestration-v2/toolOutputImageBytes.test.ts": 0.3, - "src/orchestration-v2/workflowAgentAnswers.test.ts": 0.3, + "src/orchestration-v2/runtimeLayer.test.ts": 2.8, + "src/orchestration-v2/testkit/AcpRegistryReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts": 13.7, + "src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts": 6.4, + "src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts": 4.8, + "src/orchestration-v2/testkit/GrokReplayFixtures.integration.test.ts": 5.1, + "src/orchestration-v2/testkit/MuseReplayFixtures.integration.test.ts": 2.4, + "src/orchestration-v2/testkit/OpenCodeReplayFixtures.integration.test.ts": 5.8, + "src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts": 2.2, + "src/orchestration-v2/testkit/OrchestratorReplayRestartBackgroundNote.integration.test.ts": 2.1, + "src/orchestration-v2/testkit/PiReplayFixtures.integration.test.ts": 4.3, + "src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts": 16.1, + "src/orchestration-v2/testkit/ThreadFork.integration.test.ts": 2.9, + "src/orchestration-v2/testkit/ThreadMergeBack.integration.test.ts": 5.1, "src/orchestration-v2/workflowScriptQuery.test.ts": 0.9, - "src/os-jank.test.ts": 0.2, - "src/persistence/Errors.test.ts": 0.2, "src/persistence/Migrations.test.ts": 0.5, - "src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts": 0.4, - "src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts": 0.4, "src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts": 1.1, - "src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts": 0.4, - "src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts": 0.3, - "src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts": 0.3, - "src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts": 0.4, - "src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts": 0.4, - "src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts": 0.8, - "src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts": 0.4, - "src/persistence/Migrations/041_AuthSessionClientConnection.test.ts": 0.4, - "src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts": 0.3, - "src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts": 0.4, - "src/persistence/Migrations/047_ProjectionProjectIcon.test.ts": 0.4, "src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts": 0.6, - "src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts": 0.4, "src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts": 0.5, - "src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts": 0.3, - "src/persistence/Migrations/055_OrchestrationV2.application-event-source.test.ts": 0.4, - "src/persistence/Migrations/055_OrchestrationV2.effect-cancellation.test.ts": 0.4, - "src/persistence/Migrations/055_OrchestrationV2.test.ts": 1.4, - "src/persistence/Migrations/056_RemoveRedundantProjectionIndexes.test.ts": 0.5, "src/persistence/Migrations/057_ScheduledTaskWebhooks.test.ts": 0.5, - "src/persistence/OrchestrationEventStore.sequence.test.ts": 0.6, - "src/persistence/OrchestrationEventStore.test.ts": 2.5, - "src/persistence/RepositoryErrorCorrelation.test.ts": 0.4, - "src/persistence/Sqlite.test.ts": 1.0, + "src/persistence/OrchestrationEventStore.test.ts": 0.5, "src/persistence/initializeV2Database.test.ts": 0.6, "src/persistence/reconcileV2PreviewMigration.test.ts": 1.1, - "src/preview/DesktopBrowserChannel.test.ts": 0.5, - "src/preview/Manager.test.ts": 0.3, - "src/preview/PortScanner.test.ts": 0.4, - "src/preview/PreviewBrowser.test.ts": 0.4, - "src/preview/PreviewBrowserHost.test.ts": 0.2, - "src/preview/ServerBrowser.test.ts": 0.9, "src/preview/ServerBrowserContexts.test.ts": 0.6, - "src/preview/ServerBrowserPage.test.ts": 2.7, + "src/preview/ServerBrowserPage.test.ts": 1.2, "src/preview/ServerBrowserStream.test.ts": 1.5, - "src/preview/SessionControl.test.ts": 0.1, - "src/process/agentScope.test.ts": 0.4, - "src/process/externalLauncher.test.ts": 2.7, - "src/processRunner.test.ts": 0.7, "src/project/AgentSessionImporter.test.ts": 1.5, - "src/project/AgentSessionJson.test.ts": 0.2, - "src/project/AgentSessionScanner.test.ts": 5.9, - "src/project/ManagedProjectFolders.test.ts": 2.2, + "src/project/AgentSessionScanner.test.ts": 0.8, + "src/project/ManagedProjectFolders.test.ts": 1.7, "src/project/ProjectCloneTracker.test.ts": 0.5, - "src/project/ProjectEnrichmentService.test.ts": 0.4, - "src/project/ProjectFaviconResolver.test.ts": 0.4, - "src/project/ProjectMutation.test.ts": 1.0, "src/project/ProjectService.deletion.test.ts": 0.7, "src/project/ProjectService.test.ts": 1.5, - "src/project/ProjectSetupScriptRunner.test.ts": 1.1, - "src/project/RepositoryIdentityResolver.test.ts": 0.5, - "src/project/T3ProjectFileLoader.test.ts": 0.3, "src/project/WorktreeSetupTracker.test.ts": 0.6, - "src/project/http.test.ts": 1.5, - "src/provider/AcpRegistryProbe.test.ts": 1.4, - "src/provider/AntigravityAuth.test.ts": 0.4, - "src/provider/AntigravityInstallation.test.ts": 0.5, - "src/provider/AntigravityProvider.test.ts": 0.4, + "src/provider/AcpRegistryProbe.test.ts": 0.9, "src/provider/ClaudeCapabilitiesProbe.test.ts": 0.7, - "src/provider/ClaudeModelCatalog.test.ts": 0.3, - "src/provider/CodexAuthCallback.test.ts": 0.3, - "src/provider/CodexChatGptAuth.test.ts": 7.1, - "src/provider/CodexChatGptModels.test.ts": 0.2, + "src/provider/CodexChatGptAuth.test.ts": 3.6, "src/provider/CodexChatGptSessionLock.test.ts": 0.5, - "src/provider/CodexDeveloperInstructions.test.ts": 0.1, - "src/provider/CodexInstallation.test.ts": 0.9, - "src/provider/CodexManagedErrors.test.ts": 0.2, - "src/provider/CodexManagedRuntime.test.ts": 0.4, - "src/provider/CodexMcpElicitation.test.ts": 0.1, - "src/provider/CodexProvider.catalog.test.ts": 1.1, "src/provider/CodexProvider.test.ts": 0.5, "src/provider/CodexThreadRevert.test.ts": 0.6, - "src/provider/Drivers/AntigravityDriver.test.ts": 3.0, - "src/provider/Drivers/AntigravitySkills.test.ts": 0.3, - "src/provider/Drivers/ClaudeExecutable.test.ts": 0.2, - "src/provider/Drivers/ClaudeHome.test.ts": 0.2, - "src/provider/Drivers/ClaudeSkillDispatch.test.ts": 0.1, - "src/provider/Drivers/ClaudeSkills.test.ts": 0.2, + "src/provider/Drivers/AntigravityDriver.test.ts": 1.2, "src/provider/Drivers/CodexDriver.test.ts": 0.8, - "src/provider/Drivers/CodexHomeLayout.test.ts": 0.4, - "src/provider/Drivers/antigravityUsageReader.test.ts": 0.3, - "src/provider/Drivers/claudeUsage.test.ts": 0.1, - "src/provider/Drivers/codexUsage.test.ts": 0.3, - "src/provider/EventNdjsonLogger.test.ts": 0.4, - "src/provider/GrokProvider.test.ts": 1.8, + "src/provider/GrokProvider.test.ts": 1.2, "src/provider/ModelManifest.test.ts": 0.6, - "src/provider/OpenCode2Client.events.test.ts": 0.4, - "src/provider/ProjectSettingsOverrideSweep.test.ts": 0.4, "src/provider/ProviderAuthService.test.ts": 0.5, - "src/provider/ProviderCredentialStore.test.ts": 0.4, - "src/provider/ProviderInstanceRegistry.test.ts": 10.7, + "src/provider/ProviderInstanceRegistry.test.ts": 1, "src/provider/ProviderInstanceRegistryHydration.test.ts": 1.1, - "src/provider/ProviderRegistry.test.ts": 1.3, - "src/provider/TurnTokenUsage.test.ts": 0.2, - "src/provider/acp/AcpJsonRpcConnection.test.ts": 11.1, - "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 3.7, - "src/provider/acp/AntigravityAcpSupport.test.ts": 0.4, - "src/provider/acp/AntigravityProtocol.test.ts": 0.2, - "src/provider/antigravityAuthSupport.test.ts": 0.7, - "src/provider/antigravityCallback.test.ts": 0.3, + "src/provider/ProviderRegistry.test.ts": 0.7, + "src/provider/acp/AcpJsonRpcConnection.test.ts": 6.1, + "src/provider/acp/AcpSessionRuntime.processTree.test.ts": 1, "src/provider/antigravityUsageLimits.test.ts": 0.6, - "src/provider/claudeResetCredits.test.ts": 0.2, - "src/provider/claudeUsageLimits.test.ts": 0.1, - "src/provider/codexLaunchArgs.test.ts": 0.1, - "src/provider/codexModelCatalog.test.ts": 0.3, - "src/provider/codexUsageLimits.test.ts": 0.2, "src/provider/providerCompatibility.test.ts": 1.6, "src/provider/providerInstallation.test.ts": 1.1, "src/provider/providerMaintenanceRunner.test.ts": 0.5, - "src/provider/providerStatusCache.test.ts": 0.3, - "src/provider/resetCreditCoordinator.test.ts": 0.1, - "src/provider/userInputAttachments.test.ts": 0.3, - "src/pullRequest/PullRequestProviderRateLimit.test.ts": 0.4, - "src/pullRequest/PullRequestReadCache.test.ts": 0.4, - "src/pullRequest/PullRequestReviewGuideService.test.ts": 4.0, - "src/pullRequest/PullRequestService.test.ts": 8.7, - "src/pullRequest/linkedThreads.test.ts": 0.4, - "src/pullRequest/pullRequestReviewGuideInput.test.ts": 0.1, - "src/pullRequest/pullRequestReviewGuideNormalize.test.ts": 0.1, + "src/pullRequest/PullRequestReviewGuideService.test.ts": 0.7, + "src/pullRequest/PullRequestService.test.ts": 4.6, "src/pullRequest/pullRequestSyncKey.test.ts": 0.7, "src/relay/AgentAwarenessRelay.test.ts": 1.6, - "src/resourceTelemetry/DesktopTelemetryReceiver.test.ts": 0.3, - "src/resourceTelemetry/Model.test.ts": 0.3, - "src/resourceTelemetry/NativeTelemetryClient.test.ts": 0.4, - "src/resourceTelemetry/ResourceMonitorBinary.test.ts": 0.3, "src/resourceTelemetry/ResourceTelemetry.test.ts": 0.5, - "src/resourceTelemetry/ResourceTelemetryHistory.test.ts": 0.2, - "src/review/ReviewService.test.ts": 0.4, - "src/rpcInitialItems.memory.test.ts": 8.1, - "src/scheduledTasks/Schedule.test.ts": 0.3, "src/scheduledTasks/ScheduledTaskService.schedule.test.ts": 1.5, "src/scheduledTasks/ScheduledTaskService.test.ts": 1.5, - "src/scheduledTasks/ScheduledTaskService.webhook.test.ts": 2.6, + "src/scheduledTasks/ScheduledTaskService.webhook.test.ts": 1.5, "src/scheduledTasks/webhookRoute.test.ts": 1.4, - "src/scheduledTasks/webhookTemplate.test.ts": 0.1, - "src/scheduledTasks/webhookVerification.test.ts": 0.2, - "src/scheduling/Scheduler.integration.test.ts": 1.5, - "src/scheduling/Scheduler.test.ts": 0.2, "src/secrets/SecretRequests.test.ts": 2.6, - "src/serverActivation.test.ts": 0.2, - "src/serverLifecycleEvents.test.ts": 0.4, - "src/serverLogger.test.ts": 0.3, "src/serverRuntimeStartup.autoPull.test.ts": 1.3, "src/serverRuntimeStartup.test.ts": 1.3, "src/serverRuntimeState.test.ts": 1.1, - "src/serverSettings.test.ts": 3.0, + "src/serverSettings.test.ts": 1.6, "src/serviceLauncher.test.ts": 11.2, "src/sourceControl/BitbucketApi.test.ts": 0.6, - "src/sourceControl/SourceControlDiscovery.test.ts": 0.6, "src/sourceControl/SourceControlProviderRegistry.test.ts": 0.5, - "src/sourceControl/SourceControlRepositoryService.test.ts": 0.4, - "src/sourceControl/gitHubChangeRequestTemplate.test.ts": 1.0, - "src/sourceControl/githubStackRebase.test.ts": 2.9, + "src/sourceControl/githubStackRebase.test.ts": 1.5, "src/startupAccess.test.ts": 0.5, - "src/storageCleanup.test.ts": 2.8, - "src/telemetry/AnalyticsService.test.ts": 0.4, - "src/telemetry/Identify.test.ts": 0.4, - "src/terminal/Manager.test.ts": 4.7, - "src/terminal/NodePtyAdapter.test.ts": 0.2, + "src/storageCleanup.test.ts": 1.4, + "src/terminal/Manager.test.ts": 0.9, "src/terminal/OutputProtocol.test.ts": 0.9, - "src/testUtils/weightedShardSequencer.test.ts": 0.3, "src/textGeneration/AntigravityTextGeneration.test.ts": 0.5, - "src/textGeneration/ClaudeTextGeneration.test.ts": 0.9, - "src/textGeneration/CodexTextGeneration.test.ts": 1.6, - "src/textGeneration/GrokTextGeneration.test.ts": 1.3, + "src/textGeneration/CodexTextGeneration.test.ts": 1, + "src/textGeneration/GrokTextGeneration.test.ts": 0.5, "src/textGeneration/OpenCode2TextGeneration.test.ts": 1.4, "src/textGeneration/TextGeneration.test.ts": 1.2, "src/textGeneration/ThreadTitleLinks.test.ts": 0.5, "src/usage/UsageLimitSources.test.ts": 0.6, - "src/usage/UsageService.test.ts": 2.8, + "src/usage/UsageService.test.ts": 2.2, "src/usage/cliproxyApi.test.ts": 0.5, - "src/usage/usageAggregation.test.ts": 0.2, - "src/usage/usagePricing.test.ts": 0.4, - "src/usage/usageScanCache.test.ts": 0.3, - "src/usage/usageTranscriptReader.test.ts": 0.3, "src/usage/usageTranscriptStreaming.test.ts": 1.1, - "src/utils/subscribeBeforeSnapshot.test.ts": 0.1, - "src/vcs/GitVcsDriver.test.ts": 6.5, - "src/vcs/GitVcsDriverCore.test.ts": 13.6, - "src/vcs/VcsDriverRegistry.test.ts": 0.3, - "src/vcs/VcsProcess.test.ts": 0.5, + "src/vcs/GitVcsDriver.test.ts": 2.8, + "src/vcs/GitVcsDriverCore.test.ts": 7.2, "src/vcs/VcsProjectConfig.test.ts": 0.7, - "src/vcs/VcsProvisioningService.test.ts": 0.4, "src/vcs/VcsStatusBroadcaster.test.ts": 1.3, - "src/workspace/WorkspaceEntries.test.ts": 2.5, - "src/workspace/WorkspaceFileSystem.test.ts": 0.4, - "src/workspace/WorkspacePaths.test.ts": 0.2, - "src/workspace/WorkspaceSearchIndex.test.ts": 0.2, - "src/workspace/fileHeaderMimeType.test.ts": 0.1, + "src/workspace/WorkspaceEntries.test.ts": 0.8, "src/ws.test.ts": 1.9 } diff --git a/apps/server/src/testUtils/weightedShardSequencer.test.ts b/apps/server/src/testUtils/weightedShardSequencer.test.ts deleted file mode 100644 index 4d898a1bb9d9..000000000000 --- a/apps/server/src/testUtils/weightedShardSequencer.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { TestSpecification, Vitest } from "vite-plus/test/node"; - -import shardWeights from "./shardWeights.json" with { type: "json" }; -import { WeightedShardSequencer } from "./weightedShardSequencer.ts"; - -const root = "/repo/apps/server"; -const recordedSeconds: Readonly> = shardWeights; -const recorded = Object.keys(recordedSeconds); -const files = [...recorded, ...Array.from({ length: 300 }, (_, i) => `src/fast${i}.test.ts`)]; -const specs = files.map((file) => ({ moduleId: `${root}/${file}` }) as TestSpecification); - -const shardModuleIds = (count: number) => - Promise.all( - Array.from({ length: count }, async (_, i) => { - const ctx = { config: { root, shard: { index: i + 1, count } } } as unknown as Vitest; - const shard = await new WeightedShardSequencer(ctx).shard(specs); - return shard.map((spec) => spec.moduleId); - }), - ); - -describe("WeightedShardSequencer", () => { - it("runs every file in exactly one shard", async () => { - const shards = await shardModuleIds(6); - - expect(shards.flat().toSorted()).toEqual(specs.map((spec) => spec.moduleId).toSorted()); - }); - - it("puts each of the slowest files in a different shard", async () => { - const slowest = new Set( - recorded - .toSorted((a, b) => (recordedSeconds[b] ?? 0) - (recordedSeconds[a] ?? 0)) - .slice(0, 6) - .map((file) => `${root}/${file}`), - ); - const shards = await shardModuleIds(6); - - for (const shard of shards) { - expect(shard.filter((moduleId) => slowest.has(moduleId))).toHaveLength(1); - } - }); -}); diff --git a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs b/apps/server/src/testkit/RpcInitialMemory.fixture.mjs deleted file mode 100644 index a9648abf6715..000000000000 --- a/apps/server/src/testkit/RpcInitialMemory.fixture.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import * as NodeModule from "node:module"; -import * as NodeURL from "node:url"; -import * as NodeCrypto from "node:crypto"; -import * as NodeAssert from "node:assert/strict"; - -const root = process.argv[2]; -const mode = process.argv[3]; -const count = Number(process.argv[4] ?? 4); -const bytes = Number(process.argv[5] ?? 32768); -const require = NodeModule.createRequire(root + "/apps/server/package.json"); -const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name))); -const [Effect, Stream, Layer, Deferred, Fiber] = await Promise.all( - ["Effect", "Stream", "Layer", "Deferred", "Fiber"].map(load), -); -const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts")); -const [Prefix, Ws, Threads, Events] = await Promise.all([ - app("rpcInitialItems"), - app("ws"), - app("orchestration-v2/ThreadManagementService"), - app("persistence/OrchestrationEventStore"), -]); -const refs = []; -const fibers = []; -const checkpoints = []; -const deliveredCounts = []; - -function history() { - const value = JSON.parse(JSON.stringify({ text: NodeCrypto.randomBytes(bytes).toString("hex") })); - refs.push(new WeakRef(value)); - return value; -} - -function start(ready) { - const index = deliveredCounts.push(0) - 1; - const live = Stream.fromEffect(Deferred.succeed(ready, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.never), - ); - const dependencies = Layer.mergeAll( - Layer.mock(Threads.ThreadManagementService)({ - ensureLegacyTranscript: () => Effect.void, - getThreadSnapshot: () => - Effect.sync(() => ({ - snapshotSequence: 1, - projection: { - messages: [history()], - contextHandoffs: [], - turnItems: [], - visibleTurnItems: [], - }, - })), - streamStoredEventsFrom: () => live, - }), - Layer.mock(Events.OrchestrationEventStore)({ - latestAgentSequence: () => Effect.succeed(1), - getAgentReplayStats: () => - Effect.succeed({ - eventCount: 1, - rawPayloadBytes: bytes * 2, - hasCreateEvent: false, - }), - readAgentEvents: () => - Stream.fromEffect( - Effect.sync(() => ({ - sequence: 1, - event: { type: "message.updated", payload: history() }, - })), - ), - }), - ); - const stream = - mode === "prefix" - ? Effect.succeed(Stream.concat(Prefix.rpcInitialItems([history()]), live)) - : Ws.subscribeOrchestrationV2Thread({ - threadId: "synthetic-thread", - requestCompletionMarker: true, - ...(mode === "replay" ? { afterSequence: 0 } : {}), - }).pipe(Effect.provide(dependencies)); - let delivered = 0; - return stream.pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.runForEach((item) => - Effect.sync(() => { - if (mode === "prefix") { - NodeAssert.equal(item.text.length, bytes * 2); - } else if (delivered === 0) { - NodeAssert.equal(item.kind, mode === "replay" ? "event" : "snapshot"); - const value = mode === "replay" ? item.event.payload : item.projection.messages[0]; - NodeAssert.equal(value.text.length, bytes * 2); - } else { - NodeAssert.equal(item.kind, "synchronized"); - } - delivered++; - deliveredCounts[index] = delivered; - }), - ), - Effect.forkDetach, - ), - ), - ); -} - -const result = await Effect.runPromise( - Effect.gen(function* () { - for (let i = 0; i < count; i++) { - const ready = yield* Deferred.make(); - fibers.push(yield* start(ready)); - yield* Deferred.await(ready); - NodeAssert.equal(deliveredCounts[i], mode === "prefix" ? 1 : 2); - if (i === Math.floor(count / 2) - 1 || i === count - 1) { - yield* Effect.promise(async () => { - for (let j = 0; j < 3; j++) { - await new Promise(setImmediate); - global.gc(); - } - checkpoints.push({ - subscriptions: fibers.length, - retained: refs.filter((ref) => ref.deref()).length, - payloadMiB: ((i + 1) * bytes * 2) / 1048576, - heapMiB: Math.round(process.memoryUsage().heapUsed / 1048576), - }); - }); - } - } - for (const fiber of fibers) yield* Fiber.interrupt(fiber); - return { checkpoints }; - }), -); -console.log(JSON.stringify(result)); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 454c683896d2..395a24eeedc8 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -15,8 +15,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { type AcpError, AcpRequestError } from "effect-acp/errors"; +import { type AcpError } from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; import { expect } from "vite-plus/test"; @@ -380,29 +379,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("declines elicitation and stops the helper", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ prompt: () => Effect.never }); - const child = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.forkChild); - yield* Deferred.await(fixture.enteredPrompt); - const reply = yield* fixture.incoming.question( - { - sessionId: SESSION_ID, - mode: "form", - message: "Name this branch", - requestedSchema: { type: "object", properties: {} }, - }, - requestContext, - ); - const error = yield* Fiber.join(child).pipe(Effect.flip); - expect(reply).toEqual({ action: "decline" }); - expect(error.detail).toContain("user input"); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect.each(["file", "terminal", "extension"] as const)( "denies unexpected %s requests", (kind) => @@ -490,21 +466,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("fails an unavailable model before prompting", () => - Effect.gen(function* () { - const fixture = yield* makeFixture(); - const error = yield* fixture.textGeneration - .generateThreadTitle({ - ...fixture.titleInput, - modelSelection: { ...modelSelection, model: "not-in-the-account" }, - }) - .pipe(Effect.flip); - expect(error.detail).toContain("select the Antigravity model"); - expect(fixture.state.prompts).toEqual([]); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect("uses the native default without sending T3's default selection as a model ID", () => Effect.gen(function* () { const fixture = yield* makeFixture(); @@ -550,21 +511,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("does not start another runtime or prompt after authentication fails", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ - startError: new AcpRequestError({ code: -32000, errorMessage: "Authentication required" }), - }); - const error = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.flip); - expect(error._tag).toBe("TextGenerationError"); - expect(fixture.state.workspaces).toHaveLength(1); - expect(fixture.state.prompts).toEqual([]); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect("does not launch during sign-out or before sign-in", () => Effect.gen(function* () { const fixture = yield* makeFixture({ rejectAdmission: true }); @@ -577,21 +523,6 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("times out a stalled helper and waits for cleanup", () => - Effect.gen(function* () { - const fixture = yield* makeFixture({ prompt: () => Effect.never }); - const child = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.forkChild); - yield* Deferred.await(fixture.enteredPrompt); - yield* TestClock.adjust(180_000); - const error = yield* Fiber.join(child).pipe(Effect.flip); - expect(error.detail).toContain("timed out"); - expect(fixture.state.cancellations).toBe(1); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), - ); - it.effect.each(["caller", "sign-out"] as const)( "closes all helper resources when stopped by %s", (source) => diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d6dd27e7d6e8..7a12b47b2f9e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -4,22 +4,19 @@ import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; -import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; +import { processProbeSource } from "../testUtils/processProbe.ts"; import { SYNTHETIC_CLAUDE_CAPABLE_MODEL, SYNTHETIC_CLAUDE_COLLIDING_ALIAS, SYNTHETIC_CLAUDE_MODEL_CATALOG, SYNTHETIC_CLAUDE_STANDARD_MODEL, - SYNTHETIC_CLAUDE_THINKING_MODEL, } from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; @@ -199,106 +196,6 @@ function withFakeClaudeEnv( } it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { - it.effect("kills a stalled CLI when the request times out", () => - Effect.gen(function* () { - const probe = yield* makeProcessProbe; - yield* withFakeClaudeEnv({ output: "", lifecyclePort: probe.port }, (textGeneration) => - Effect.gen(function* () { - const fiber = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* probe.started; - - yield* TestClock.adjust("180 seconds"); - yield* probe.exited; - expect((yield* Fiber.join(fiber)).detail).toBe("Claude CLI request timed out."); - }), - ); - }), - ); - - it.effect.each([ - { thinking: true, settings: '{"disableAllHooks":true,"alwaysThinkingEnabled":true}' }, - { thinking: false, settings: '{"disableAllHooks":true,"alwaysThinkingEnabled":false}' }, - { thinking: undefined, settings: '{"disableAllHooks":true}' }, - ])("forwards only the explicit thinking choice ($thinking)", ({ thinking, settings }) => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - subject: "Add important change", - body: "", - }, - }), - argsMustContain: `--settings ${settings}`, - argsMustNotContain: "--effort", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/claude-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - SYNTHETIC_CLAUDE_THINKING_MODEL, - [ - ...(thinking === undefined ? [] : [{ id: "thinking", value: thinking }]), - { id: "effort", value: "high" }, - ], - ), - }); - - expect(generated.subject).toBe("Add important change"); - }), - ), - ); - - it.effect("keeps a configured custom alias opaque to the Claude CLI", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: "Keep custom model", - body: "", - }, - }), - argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --effort max --settings`, - claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/custom-model", - commitSummary: "Keep custom model", - diffSummary: "1 file changed", - diffPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - SYNTHETIC_CLAUDE_COLLIDING_ALIAS, - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - { id: "contextWindow", value: "expanded" }, - ], - ), - }); - - expect(generated.title).toBe("Keep custom model"); - }), - ), - ); - it.effect( "keeps canonical built-in capabilities when a custom model collides with its alias", () => @@ -373,28 +270,6 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { ), ); - it.effect("generates branch names from skill prompts without executable capabilities", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ structured_output: { branch: "call-script" } }), - stdinMustContain: "/call-script", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "/call-script", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.branch).toBe("call-script"); - }), - ), - ); - it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -554,29 +429,4 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ), ); - - it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: ' """ """ ', - }, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - }, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 29ae74e97c04..b57ed29988c7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -7,13 +7,12 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; -import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; @@ -236,60 +235,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }), ), ); - it.effect("generates and sanitizes commit messages without branch by default", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: - " Add important change to the system with too much detail and a trailing period.\nsecondary line", - body: "\n- added migration\n- updated tests\n", - }), - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject.length).toBeLessThanOrEqual(72); - expect(generated.subject.endsWith(".")).toBe(false); - expect(generated.body).toBe("- added migration\n- updated tests"); - expect(generated.branch).toBeUndefined(); - }), - ), - ); - - it.effect( - "forwards codex service tier and non-default reasoning effort into codex exec config", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireServiceTier: "priority", - requireReasoningEffort: "xhigh", - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "serviceTier", value: "priority" }, - ]), - }), - ), - ); it.effect("omits a persisted service tier for managed ChatGPT text generation", () => withFakeCodexEnv( @@ -397,166 +342,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("uses T3CODE_CODEX_LAUNCH_ARGS for codex exec over settings", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - launchArgs: "--enable settings-feature", - environment: { ...process.env, T3CODE_CODEX_LAUNCH_ARGS: " --strict-config --listen off " }, - requireArg: "--strict-config", - forbidArg: "settings-feature", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }), - ), - ); - - it.effect("defaults git text generation codex effort to low", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireReasoningEffort: "low", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }), - ), - ); - - it.effect("generates commit message with branch when includeBranch is true", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - branch: "fix/important-system-change", - }), - stdinMustContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - includeBranch: true, - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject).toBe("Add important change"); - expect(generated.branch).toBe("fix/important-system-change"); - }), - ), - ); - - it.effect("generates PR content and trims markdown body", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: " Improve orchestration flow\nwith ignored suffix", - body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/codex-effect", - commitSummary: "feat: improve orchestration flow", - diffSummary: "2 files changed", - diffPatch: "diff --git a/a.ts b/a.ts", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("Improve orchestration flow"); - expect(generated.body.startsWith("## Summary")).toBe(true); - expect(generated.body.endsWith("\n\n")).toBe(false); - }), - ), - ); - - it.effect("generates branch names and normalizes branch fragments", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: " Feat/Session ", - }), - stdinMustNotContain: "Image attachments supplied to the model", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Please update session handling.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("feat/session"); - }), - ), - ); - - it.effect.each([ - { - mode: "static", - output: "Add Search", - expected: "team/add-search", - instruction: "without a prefix or namespace", - }, - { - mode: "semantic", - output: "feat/add-search", - expected: "feat/add-search", - instruction: "semantic prefix", - }, - { - mode: "custom", - output: "Julius/ABC-123.v2", - expected: "Julius/ABC-123.v2", - instruction: "Preserve the issue ID and capitalization.", - }, - ] as const)("generates a branch using $mode naming", (example) => - withFakeCodexEnv( - { - output: JSON.stringify({ branch: example.output }), - stdinMustContain: example.instruction, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Add search", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - naming: { - mode: example.mode, - prefix: "team/", - instructions: "Preserve the issue ID and capitalization.", - }, - }); - expect(generated.branch).toBe(example.expected); - }), - ), - ); - it.effect("generates branch names even when the ambient scope is already closed", () => withFakeCodexEnv( { @@ -585,29 +370,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("generates thread titles and trims them for sidebar use", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: - ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate websocket reconnect regressions after a worktree restore.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe( - "Investigate websocket reconnect regressions after worktree restore", - ); - }), - ), - ); - it.effect("returns the refinement signal for an unresolved subject", () => withFakeCodexEnv( { output: JSON.stringify({ title: "Investigate issue", needsRefinement: true }) }, @@ -624,106 +386,6 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); - it.effect("falls back when thread title normalization becomes whitespace-only", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ' """ """ ', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); - - it.effect("trims whitespace exposed after quote removal in thread titles", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ` "' hello world '" `, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("hello world"); - }), - ), - ); - - it.effect("omits attachment metadata section when no attachments are provided", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/session-timeout", - }), - stdinMustNotContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Fix timeout behavior.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("fix/session-timeout"); - }), - ), - ); - - it.effect("passes image attachments through as codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - stdinMustContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; - const attachmentId = "thread-branch-image-attachment"; - const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(attachmentPath, Buffer.from("hello")); - - const generated = yield* textGeneration.generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - it.effect("resolves persisted attachment ids to files for codex image inputs", () => withFakeCodexEnv( { @@ -772,105 +434,4 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }), ), ); - - it.effect("ignores missing attachment ids for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig.ServerConfig; - const missingAttachmentId = "thread-missing-attachment"; - const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.ignore); - - const result = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: missingAttachmentId, - name: "outside.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("missing --image input"); - } - }), - ), - ); - - it.effect( - "fails with typed TextGenerationError when codex returns wrong branch payload shape", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: "This is not a branch payload", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateBranchName({ - cwd: process.cwd(), - message: "Fix websocket reconnect flake", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("Codex returned invalid structured output"); - } - }), - ), - ); - - it.effect("returns typed TextGenerationError when codex exits non-zero", () => - withFakeCodexEnv( - { - output: JSON.stringify({ subject: "ignored", body: "" }), - exitCode: 1, - stderr: "codex execution failed", - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain( - "Codex CLI command failed: codex execution failed", - ); - } - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 80f5c236259d..c74f77db4d06 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -132,93 +132,4 @@ it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => { }), ), ); - - it.effect("surfaces ACP request failures as text generation errors", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ branch: "unreachable" }), - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "wire up grok", - modelSelection: createModelSelection( - ProviderInstanceId.make("grok"), - "missing-grok-model", - ), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toContain("Grok ACP base model"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is empty", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: " \n ", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/empty/i); - }), - ), - ); - - it.effect("decodes a structured PR title + body", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: "feat(grok): wire up session/set_model", - body: "## Summary\n- Replace `-m` spawn flag with the typed ACP `session/set_model`.\n- Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT` into a validation error.", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feat/grok-provider", - commitSummary: "feat: add grok provider", - diffSummary: "M apps/server/src/provider/Drivers/GrokDriver.ts", - diffPatch: "diff --git a/.../GrokDriver.ts b/.../GrokDriver.ts", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }); - - expect(generated.title).toBe("feat(grok): wire up session/set_model"); - expect(generated.body).toContain("Translate `MODEL_SWITCH_INCOMPATIBLE_AGENT`"); - }), - ), - ); - - it.effect("fails with TextGenerationError when output is unparseable JSON", () => - withFakeAcpGrok( - { - T3_ACP_PROMPT_RESPONSE_TEXT: "totally not json output from a confused model", - }, - (textGeneration) => - Effect.gen(function* () { - const error = yield* Effect.flip( - textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection(ProviderInstanceId.make("grok"), "grok-build"), - }), - ); - expect(error._tag).toBe("TextGenerationError"); - expect(error.detail).toMatch(/invalid structured output/i); - }), - ), - ); }); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index 82c3bd165b19..775d2542d67e 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -37,69 +37,6 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("fails at once when the event stream drops before the reply", () => - Effect.gen(function* () { - const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; - const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; - const server = yield* OpenCode2AdapterV2Testkit.replayServer({ - provider: "opencode", - protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, - version: "2.0.18", - scenario: "opencode2_title_generation_stream_lost", - entries: [ - subscribe!, - connected!, - create!, - created!, - { - type: "expect_outbound", - frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, - }, - { - type: "emit_inbound", - frame: { - type: "sdk.response", - operation: "session.prompt", - data: { - data: { - id: "msg_1", - sessionID: sessionId, - time: { created: 1 }, - type: "user", - payload: { text: "t" }, - delivery: "steer", - }, - }, - }, - }, - { type: "runtime_exit", status: "success" }, - { - type: "expect_outbound", - frame: { type: "session.remove", input: { sessionID: sessionId } }, - }, - { - type: "emit_inbound", - frame: { type: "sdk.response", operation: "session.remove", data: null }, - }, - ], - }); - const textGeneration = yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, server), - ); - const failure = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "fix the login redirect loop after oauth", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "opencode/big-pickle", - }, - }) - .pipe(Effect.flip, Effect.timeout("10 seconds")); - assert.equal(failure._tag, "TextGenerationError"); - }).pipe(Effect.scoped), - ); - it.effect("keeps a provider's own error message out of the caller-visible detail", () => Effect.gen(function* () { const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; @@ -182,81 +119,4 @@ it.layer(layer)("OpenCode2TextGeneration", (it) => { assert.deepEqual(failure.cause, refusal); }).pipe(Effect.scoped), ); - - it.effect("ends at once on an execution end this build cannot decode", () => - Effect.gen(function* () { - const [subscribe, connected, create, created] = OPENCODE2_TITLE_GENERATION; - const sessionId = "ses_f0ec85cf6ffebfalpvV2E9nocu"; - const server = yield* OpenCode2AdapterV2Testkit.replayServer({ - provider: "opencode", - protocol: OpenCode2AdapterV2Testkit.OPENCODE2_HTTP_PROTOCOL, - version: "2.0.18", - scenario: "opencode2_title_generation_unreadable_end", - entries: [ - subscribe!, - connected!, - create!, - created!, - { - type: "expect_outbound", - frame: { type: "session.prompt", input: { sessionID: sessionId, text: "" } }, - }, - { - type: "emit_inbound", - frame: { - type: "sdk.response", - operation: "session.prompt", - data: { - data: { - id: "msg_1", - sessionID: sessionId, - time: { created: 1 }, - type: "user", - payload: { text: "t" }, - delivery: "steer", - }, - }, - }, - }, - // A reason added after 2.0.18: the full schema rejects the frame. - { - type: "emit_inbound", - frame: { - type: "sdk.event", - event: { - id: "evt_0eb7f9ae0001VCNDHVLnqTfoPG", - created: 1790657403616, - type: "session.execution.interrupted", - data: { sessionID: sessionId, reason: "budget" }, - durable: { aggregateID: sessionId, seq: 7, version: 1 }, - }, - }, - }, - { - type: "expect_outbound", - frame: { type: "session.remove", input: { sessionID: sessionId } }, - }, - { - type: "emit_inbound", - frame: { type: "sdk.response", operation: "session.remove", data: null }, - }, - { type: "runtime_exit", status: "success" }, - ], - }); - const textGeneration = yield* OpenCode2TextGeneration.make().pipe( - Effect.provideService(OpenCode2Server.OpenCode2Server, server), - ); - const failure = yield* textGeneration - .generateThreadTitle({ - cwd: process.cwd(), - message: "fix the login redirect loop after oauth", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "opencode/big-pickle", - }, - }) - .pipe(Effect.flip, Effect.timeout("10 seconds")); - assert.equal(failure._tag, "TextGenerationError"); - }).pipe(Effect.scoped), - ); }); diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts index bf4323a195e9..ccb4cbfde9fc 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.test.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.test.ts @@ -3,9 +3,6 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; -import * as Fiber from "effect/Fiber"; -import * as Deferred from "effect/Deferred"; import { SourceControlProviderError } from "@t3tools/contracts"; import { resolveThreadTitleLinks } from "./ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -14,7 +11,6 @@ const layerRegistry = Layer.mock(SourceControlProviderRegistry.SourceControlProv const encodeSubject = Schema.encodeSync( Schema.fromJsonString(Schema.Struct({ title: Schema.String, body: Schema.String })), ); -const success = { title: "Fix QR pairing expiry", body: "Keep remote connections working." }; it.effect( "uses provider-selected links, deduplicates anchors, and bounds lookups and summaries", @@ -51,31 +47,6 @@ it.effect( }), ); -it.effect("returns unavailable when a lookup times out while retaining successful subjects", () => - Effect.gen(function* () { - const started = yield* Deferred.make(); - const fiber = yield* resolveThreadTitleLinks({ - cwd: "/tmp/project", - message: "https://forge.test/change/1 https://forge.test/change/2", - }).pipe( - Effect.provide( - layerRegistry({ - resolveLink: ({ url }) => - url.pathname.endsWith("1") - ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.succeed(success), - }), - ), - Effect.forkChild, - ); - yield* Deferred.await(started); - yield* TestClock.adjust("3 seconds"); - expect(yield* Fiber.join(fiber)).toBe( - `https://forge.test/change/1: unavailable\n\nhttps://forge.test/change/2\n${encodeSubject(success)}`, - ); - }), -); - it.effect("keeps lookup failure out of generation and skips unlinked messages", () => Effect.gen(function* () { expect(yield* resolveThreadTitleLinks({ cwd: "/tmp", message: "Fix pairing" })).toBeUndefined(); diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index 8821a45a6b28..b8976fca81e7 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -1,19 +1,10 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; -import { - ServerConfigStreamEvent, - type ServerSettings as ServerSettingsValue, - UsageLimitSourceId, -} from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; +import { ServerConfigStreamEvent, UsageLimitSourceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { HttpClient } from "effect/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -92,102 +83,3 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => Effect.scoped, ), ); - -it.effect.each([ - { interval: Duration.seconds(0), expectedReads: 1, mode: "disabled" }, - { interval: Duration.minutes(1), expectedReads: 2, mode: "enabled" }, -])( - "honors $mode polling while preserving explicit and settings refreshes", - ({ interval, expectedReads }) => - Effect.gen(function* () { - const settings = yield* ServerSettings.ServerSettingsService; - const settingsChanges = yield* PubSub.unbounded(); - const reads = yield* Ref.make(0); - const allowBackgroundWork = yield* Ref.make(true); - const sources = yield* UsageLimitSources.make.pipe( - Effect.provide([ - Layer.succeed(ServerSettings.ServerSettingsService, { - ...settings, - streamChanges: Stream.fromPubSub(settingsChanges), - }), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: (scope) => - scope.type === "provider-status" - ? Ref.get(allowBackgroundWork) - : Effect.succeed(false), - }), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Ref.update(reads, (count) => count + 1).pipe( - Effect.as(HttpClientResponse.fromWeb(request, Response.json({ files: [] }))), - ), - ), - ), - NodeCrypto.layer, - ]), - ); - yield* sources.streamChanges.pipe( - Stream.filter((snapshots) => snapshots.length === 1), - Stream.runHead, - ); - expect((yield* sources.current)[0]?.error).toBeUndefined(); - expect(yield* Ref.get(reads)).toBe(1); - - yield* TestClock.adjust("1 minute"); - expect(yield* Ref.get(reads)).toBe(expectedReads); - - yield* sources.refresh; - expect(yield* Ref.get(reads)).toBe(expectedReads + 1); - - const current = yield* settings.getSettings; - const updated = yield* settings.updateSettings({ - usageLimitSources: { - [UsageLimitSourceId.make("hub")]: { - ...current.usageLimitSources[UsageLimitSourceId.make("hub")]!, - label: "Renamed hub", - }, - }, - }); - yield* PubSub.publish(settingsChanges, updated); - yield* sources.streamChanges.pipe( - Stream.filter((snapshots) => snapshots[0]?.label === "Renamed hub"), - Stream.runHead, - ); - expect(yield* Ref.get(reads)).toBe(expectedReads + 2); - - yield* Ref.set(allowBackgroundWork, false); - yield* TestClock.adjust("1 minute"); - expect(yield* Ref.get(reads)).toBe(expectedReads + 2); - - const beforeResuming = (yield* sources.current)[0]?.checkedAt; - yield* Ref.set(allowBackgroundWork, true); - yield* settings.updateSettings({ providerHealthRefreshInterval: Duration.minutes(1) }); - yield* TestClock.adjust("2 minutes"); - expect(yield* Ref.get(reads)).toBeGreaterThan(expectedReads + 2); - yield* sources.streamChanges.pipe( - Stream.filter((snapshots) => snapshots[0]?.checkedAt !== beforeResuming), - Stream.runHead, - ); - yield* TestClock.adjust("30 seconds"); - const readsBeforeDisabling = yield* Ref.get(reads); - yield* settings.updateSettings({ providerHealthRefreshInterval: Duration.seconds(0) }); - yield* TestClock.adjust("30 seconds"); - expect(yield* Ref.get(reads)).toBe(readsBeforeDisabling); - }).pipe( - Effect.provide( - ServerSettings.layerTest({ - providerHealthRefreshInterval: interval, - usageLimitSources: { - [UsageLimitSourceId.make("hub")]: { - kind: "cliproxy", - url: "https://hub.test", - managementKey: "test-key", - enabled: true, - }, - }, - }), - ), - Effect.scoped, - ), -); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 030ced9663dd..267091ed5002 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -229,7 +229,6 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens const CURSOR_NOW = Date.parse("2026-08-02T12:00:00Z"); const HOUR_MS = 60 * 60 * 1000; -const CURSOR_RETENTION_MS = 92 * 24 * HOUR_MS; function makeFakeCursor() { const state = { @@ -1014,26 +1013,6 @@ describe("UsageService", () => { ); }).pipe(Effect.scoped), ); - it.live("omits Cursor account usage when no file login is saved", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - for (const platform of ["linux", "win32", "darwin"] as const) { - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: `usage-service-cursor-no-login-${platform}`, - home, - settings, - platform, - environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, - }), - ), - ); - const summary = yield* service.readSummary(WINDOW); - assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); - } - }).pipe(Effect.scoped), - ); it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => Effect.gen(function* () { @@ -1054,138 +1033,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("answers Cursor from its cache while one shared refresh runs", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [{ timestampMs: CURSOR_NOW - 2 * HOUR_MS, outputTokens: 5 }]; - const gate = yield* Deferred.make(); - cursor.state.gate = gate; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - const cold = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(cold)?.refreshing, true); - assert.strictEqual(cursorSource(cold)?.status, "ok"); - assert.strictEqual(totalOutputTokens(cold), 0); - const joined = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(joined)?.refreshing, true); - - const waited = yield* service - .readSummary({ ...WINDOW, awaitRefresh: true }) - .pipe(Effect.forkChild); - yield* Deferred.succeed(gate, undefined); - const refreshed = yield* Fiber.join(waited); - assert.isFalse(refreshed.sources.some((source) => source.refreshing)); - assert.strictEqual(cursorSource(refreshed)?.status, "ok"); - assert.strictEqual(totalOutputTokens(refreshed), 5); - assert.strictEqual(cursor.state.calls.length, 1); - - yield* TestClock.adjust(Duration.seconds(30)); - const cached = yield* service.readSummary(WINDOW); - assert.isUndefined(cursorSource(cached)?.refreshing); - assert.strictEqual(totalOutputTokens(cached), 5); - assert.strictEqual(cursor.state.calls.length, 1); - - yield* TestClock.adjust(Duration.seconds(31)); - cursor.state.events.push({ timestampMs: CURSOR_NOW, outputTokens: 7 }); - const stale = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(stale)?.refreshing, true); - assert.strictEqual(totalOutputTokens(stale), 5); - const fresh = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.isUndefined(cursorSource(fresh)?.refreshing); - assert.strictEqual(totalOutputTokens(fresh), 12); - assert.strictEqual(cursor.state.calls.length, 2); - }).pipe(Effect.provide(layerService({ prefix: "usage-service-cursor-swr", home, settings }))); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("caches Cursor's whole retention and then refetches only the newest edge", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [ - { timestampMs: Date.parse("2026-07-10T10:00:00Z"), outputTokens: 17 }, - { timestampMs: CURSOR_NOW - 24 * HOUR_MS, outputTokens: 5 }, - { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, - { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, - ]; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - const read = (input: UsageSummaryInput) => - service.readSummary({ ...input, awaitRefresh: true }); - assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); - assert.deepStrictEqual(cursor.state.calls, [ - { sinceMs: CURSOR_NOW - CURSOR_RETENTION_MS, untilMs: CURSOR_NOW }, - ]); - - const wide = { ...WINDOW, sinceDay: UsageDay.make("2026-07-01") }; - assert.strictEqual(totalOutputTokens(yield* read(wide)), 36); - assert.strictEqual(cursor.state.calls.length, 1); - - cursor.state.events.push( - { timestampMs: CURSOR_NOW - HOUR_MS / 6, outputTokens: 11 }, - { timestampMs: CURSOR_NOW + HOUR_MS, outputTokens: 13 }, - ); - yield* TestClock.adjust(Duration.hours(2)); - assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 43); - assert.deepStrictEqual(cursor.state.calls[1], { - sinceMs: CURSOR_NOW - HOUR_MS, - untilMs: CURSOR_NOW + 2 * HOUR_MS, - }); - - cursor.state.accountKey = "account-b"; - yield* TestClock.adjust(Duration.minutes(2)); - assert.strictEqual(totalOutputTokens(yield* read(wide)), 60); - assert.strictEqual(cursor.state.calls.length, 4); - assert.strictEqual(cursorSource(yield* read(wide))?.fingerprint.volumeId, "account-b"); - }).pipe( - Effect.provide( - layerService({ prefix: "usage-service-cursor-incremental", home, settings }), - ), - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("reports a failed Cursor refresh from its cache without refreshing", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - yield* writeCursorLogin(home); - yield* TestClock.setTime(CURSOR_NOW); - const cursor = makeFakeCursor(); - cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; - yield* Effect.gen(function* () { - const service = yield* makeWithCursor(cursor.read); - yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - - yield* TestClock.adjust(Duration.minutes(2)); - cursor.state.error = "Sign in to Cursor again to read account usage."; - const failed = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.strictEqual(cursorSource(failed)?.status, "partial"); - assert.strictEqual(cursorSource(failed)?.message, cursor.state.error); - assert.isUndefined(cursorSource(failed)?.refreshing); - assert.strictEqual(totalOutputTokens(failed), 5); - - const again = yield* service.readSummary(WINDOW); - assert.strictEqual(cursorSource(again)?.status, "partial"); - assert.isUndefined(cursorSource(again)?.refreshing); - assert.strictEqual(cursor.state.calls.length, 2); - - yield* TestClock.adjust(Duration.minutes(2)); - cursor.state.accountKey = "account-b"; - const switched = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); - assert.strictEqual(cursorSource(switched)?.status, "missing"); - assert.strictEqual(cursorSource(switched)?.message, cursor.state.error); - assert.strictEqual(totalOutputTokens(switched), 0); - }).pipe( - Effect.provide(layerService({ prefix: "usage-service-cursor-failure", home, settings })), - ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live("restores the Cursor account cache after a restart", () => Effect.gen(function* () { const { settings, home } = yield* setup; @@ -1873,24 +1720,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("counts appended usage on a rescan of a grown transcript", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - const service = yield* UsageService.make.pipe( - Effect.provide(layerService({ prefix: "usage-service-grow-test", home, settings })), - ); - - const first = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(first), 5); - - yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); - const second = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(second), 12); - }).pipe(Effect.scoped), - ); - it.live( "retains Codex reconciliation and partial coverage through persisted restart, append and repair", () => @@ -2069,7 +1898,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.each([4, 5, 6])( + it.live.each([4, 5, 6, 7])( "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts and the old file", (version) => Effect.gen(function* () { @@ -2159,7 +1988,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v7.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v8.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, @@ -2355,48 +2184,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "keeps the later read when a scan that read earlier finishes first", - () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - const dir = NodePath.dirname(transcript); - const gate = (name: string) => NodePath.join(dir, `${name}.jsonl`); - const wideProbe = gate("wide-probe"); - const wideHold = gate("wide-hold"); - const narrowProbe = gate("narrow-probe"); - const narrowHold = gate("narrow-hold"); - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - yield* makeGate(wideProbe, BEFORE_NARROW_WINDOW); - yield* makeGate(wideHold, BEFORE_NARROW_WINDOW); - yield* Effect.gen(function* () { - const service = yield* UsageService.make; - const wide = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); - yield* openGate(wideProbe); - yield* replaceFile(transcript, claudeLine(1, 5) + claudeLine(2, 7)); - yield* makeGate(narrowProbe); - yield* makeGate(narrowHold); - const narrow = yield* service.readSummary(NARROW_WINDOW).pipe(Effect.forkChild); - yield* openGate(narrowProbe); - yield* openGate(wideHold); - yield* Fiber.join(wide); - yield* openGate(narrowHold); - yield* Fiber.join(narrow); - - yield* Effect.promise(() => - Promise.all( - [transcript, wideProbe, wideHold, narrowProbe, narrowHold].map((path) => - NodeFSP.rm(path), - ), - ), - ); - assert.strictEqual(totalOutputTokens(yield* service.readSummary(WINDOW)), 12); - }).pipe( - Effect.provide(layerService({ prefix: "usage-service-late-read-test", home, settings })), - ); - }).pipe(Effect.scoped), - ); - it.live("keeps an absent directory missing when its cached files only contain diagnostics", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -2534,37 +2321,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("shares one scan between concurrent identical requests", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - let ratesFetches = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-flight-test", - home, - settings, - onRatesFetch: () => { - ratesFetches += 1; - }, - }), - ), - ); - - const [first, second] = yield* Effect.all( - [service.readSummary(WINDOW), service.readSummary(WINDOW)], - { concurrency: 2 }, - ); - assert.deepStrictEqual(first, second); - assert.strictEqual(ratesFetches, 2); - - yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 2); - }).pipe(Effect.scoped), - ); - it.live("scopes catalog rates to Devin when provider model names collide", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -2792,88 +2548,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); - it.live("refetches a rate table inside its TTL only when the client asks", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - - let ratesFetches = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-refresh-test", - home, - settings, - ratesDocument: { - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }, - onRatesFetch: () => { - ratesFetches += 1; - }, - }), - ), - ); - - const first = yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 1); - assert.strictEqual(first.pricing.status, "fresh"); - - yield* TestClock.adjust(Duration.minutes(2)); - yield* service.readSummary(WINDOW); - assert.strictEqual(ratesFetches, 1); - - const [refreshed] = yield* Effect.all([service.refreshRates, service.refreshRates], { - concurrency: 2, - }); - assert.strictEqual(ratesFetches, 2); - assert.strictEqual(refreshed.status, "fresh"); - assert.strictEqual(refreshed.knownModels, 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("preserves cached estimates when both pricing downloads fail and recovers", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); - let status = 200; - const requests: string[] = []; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-failure-test", - home, - settings, - ratesDocument: { - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }, - ratesStatus: () => status, - onRatesFetch: (request) => requests.push(request.url), - }), - ), - ); - const first = yield* service.readSummary(WINDOW); - yield* TestClock.adjust(Duration.minutes(2)); - status = 503; - - const failed = yield* service.refreshRates; - assert.strictEqual(failed.status, "cached"); - assert.strictEqual(failed.fetchedAt, first.pricing.fetchedAt); - assert.strictEqual(requests.length, 3); - assert.strictEqual( - requests[2], - "https://api.github.com/repos/BerriAI/litellm/contents/model_prices_and_context_window.json?ref=main", - ); - const cached = yield* service.readSummary(WINDOW); - assert.deepStrictEqual(cached.buckets, first.buckets); - - status = 200; - yield* TestClock.adjust(Duration.minutes(1)); - const recovered = yield* service.refreshRates; - assert.strictEqual(recovered.status, "fresh"); - assert.notStrictEqual(recovered.fetchedAt, first.pricing.fetchedAt); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live.each(["network", "http", "invalidJson", "empty"] as const)( "recovers pricing through the GitHub API after a %s failure", (failure) => @@ -2933,126 +2607,6 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("tries the GitHub API after the primary pricing request times out", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const primaryStarted = yield* Deferred.make(); - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-timeout-test", - home, - settings, - ratesResponse: (request) => - request.url.startsWith("https://api.github.com/") - ? Effect.succeed( - Response.json({ - "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, - }), - ) - : Deferred.succeed(primaryStarted, undefined).pipe(Effect.andThen(Effect.never)), - }), - ), - ); - const refresh = yield* service.refreshRates.pipe(Effect.forkChild); - yield* Deferred.await(primaryStarted); - yield* TestClock.adjust(Duration.seconds(10)); - const pricing = yield* Fiber.join(refresh); - assert.strictEqual(pricing.status, "fresh"); - assert.strictEqual(pricing.knownModels, 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - - it.live("keeps pricing unavailable when neither download has usable rates", () => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const service = yield* UsageService.make.pipe( - Effect.provide(layerService({ prefix: "usage-service-rates-empty-test", home, settings })), - ); - const pricing = yield* service.refreshRates; - assert.strictEqual(pricing.status, "unavailable"); - assert.isNull(pricing.fetchedAt); - }).pipe(Effect.scoped), - ); - - it.live.each([false, true])( - "throttles failed pricing loads from completion with cached rates: %s", - (hasCachedRates) => - Effect.gen(function* () { - const { settings, home } = yield* setup; - const primaryStarted = yield* Deferred.make(); - const fallbackStarted = yield* Deferred.make(); - let responding = true; - let requests = 0; - const service = yield* UsageService.make.pipe( - Effect.provide( - layerService({ - prefix: "usage-service-rates-retry-floor-test", - home, - settings, - onRatesFetch: () => { - requests += 1; - }, - ratesResponse: (request) => - responding - ? Effect.succeed( - Response.json({ - "claude-fable-5": { - input_cost_per_token: 1e-5, - output_cost_per_token: 5e-5, - }, - }), - ) - : Deferred.succeed( - request.url.startsWith("https://api.github.com/") - ? fallbackStarted - : primaryStarted, - undefined, - ).pipe(Effect.andThen(Effect.never)), - }), - ), - ); - let fetchedAt: string | null = null; - if (hasCachedRates) { - fetchedAt = (yield* service.refreshRates).fetchedAt; - yield* TestClock.adjust(Duration.hours(24)); - } - responding = false; - const refresh = yield* service.refreshRates.pipe(Effect.forkChild); - yield* Deferred.await(primaryStarted); - yield* TestClock.adjust(Duration.seconds(10)); - yield* Deferred.await(fallbackStarted); - yield* TestClock.adjust(Duration.seconds(10)); - const failed = yield* Fiber.join(refresh); - const expectedStatus = hasCachedRates ? "cached" : "unavailable"; - assert.strictEqual(failed.status, expectedStatus); - assert.strictEqual(failed.fetchedAt, fetchedAt); - const requestsAfterFailure = requests; - assert.strictEqual(requestsAfterFailure, hasCachedRates ? 3 : 2); - - responding = true; - const [immediate, summary] = yield* Effect.all( - [service.refreshRates, service.readSummary(WINDOW)], - { concurrency: 2 }, - ); - assert.strictEqual(immediate.status, expectedStatus); - assert.strictEqual(summary.pricing.status, expectedStatus); - assert.strictEqual(requests, requestsAfterFailure); - yield* TestClock.adjust(Duration.seconds(59)); - const coolingDown = yield* service.refreshRates; - assert.strictEqual(coolingDown.status, expectedStatus); - assert.strictEqual(coolingDown.fetchedAt, fetchedAt); - assert.strictEqual(requests, requestsAfterFailure); - - yield* TestClock.adjust(Duration.seconds(1)); - const recovered = yield* service.refreshRates; - assert.strictEqual(recovered.status, "fresh"); - assert.strictEqual(recovered.knownModels, 1); - assert.notStrictEqual(recovered.fetchedAt, fetchedAt); - assert.strictEqual(requests, requestsAfterFailure + 1); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), - ); - it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 64dbd18ff1f2..58e1f2ed2fd1 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -1,7 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; @@ -164,6 +163,7 @@ describe("CLIProxyAPI hub failures", () => { }), ); + // regression: #18168 Limits hub failures name their TLS, HTTP, or timeout cause it.effect("ignores a transport code that is not a short Node error code", () => Effect.gen(function* () { const api = yield* unreachableHub( @@ -199,40 +199,6 @@ describe("CLIProxyAPI hub failures", () => { expect(error.detail).not.toContain("management-secret"); }), ); - - it.effect("says when the hub does not answer in time", () => - Effect.gen(function* () { - const api = yield* makeCliproxyApi.pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.never), - ), - Effect.provide(NodeCrypto.layer), - ); - const pending = yield* Effect.flip(api.readAccounts(config)).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust("15 seconds"); - const error = yield* Fiber.join(pending); - expect(error.detail).toContain("did not answer within 15 seconds"); - }), - ); - - it.effect("keeps a malformed account list generic", () => - Effect.gen(function* () { - const test = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json({ nope: "hub-internal" })), - ), - ); - const api = yield* makeCliproxyApi.pipe( - Effect.provideService(HttpClient.HttpClient, test), - Effect.provide(NodeCrypto.layer), - ); - const error = yield* Effect.flip(api.readAccounts(config)); - expect(error.detail).toBe("The hub could not list accounts."); - }), - ); }); describe("CLIProxyAPI built-in management API", () => { @@ -411,21 +377,6 @@ describe("CLIProxyAPI built-in management API", () => { }), ); - it.effect.each([ - ["nothing_to_reset", "nothingToReset"], - ["no_credit", "noCredit"], - ["already_redeemed", "alreadyRedeemed"], - ] as const)("reports %s accurately", ([code, outcome]) => - Effect.gen(function* () { - const test = fixture({ upstream: () => ({ status: 200, body: { code } }) }); - const api = yield* test.api; - expect(yield* api.consume(config, "first.json", "credit")).toEqual({ outcome }); - expect(test.requests.some((request) => request.path.endsWith("/reset-quota"))).toBe( - code === "already_redeemed", - ); - }), - ); - it.effect("reports redemption success even if cooldown clearing fails", () => Effect.gen(function* () { const api = yield* fixture({ cooldownStatus: 404 }).api; diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index f51e140b4e2b..254502811e19 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -108,19 +108,6 @@ describe("UsageAggregator", () => { ); }); - it("requires exact bounds for hourly aggregation", () => { - expect( - () => - new UsageAggregator({ - timeZone: "UTC", - sinceDay: "2026-08-01", - untilDay: "2026-08-31", - resolution: "hour", - rates, - }), - ).toThrow("requires exact time bounds"); - }); - it("splits a bucket's cost by category and speed", () => { const [bucket] = aggregate([record(), record({ speed: "fast" })]).buckets; @@ -151,13 +138,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.totals.outputTokens).toBe(50); }); - it("still sums records that carry no dedupe key", () => { - const result = aggregate([record(), record()]); - - expect(result.duplicatesDropped).toBe(0); - expect(result.buckets[0]?.totals.outputTokens).toBe(100); - }); - it("buckets by the day in the requested time zone", () => { const utc = aggregate([record()], "UTC"); const losAngeles = aggregate([record()], "America/Los_Angeles"); @@ -234,24 +214,6 @@ describe("UsageAggregator", () => { ]); }); - it("keeps daily payloads collapsed when hourly resolution is not requested", () => { - const result = aggregate([ - record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), - record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }), - ]); - - expect(result.buckets).toHaveLength(1); - expect(result.buckets[0]?.hourStart).toBeUndefined(); - expect(result.buckets[0]?.records).toBe(2); - }); - - it("prices against the rate table", () => { - const result = aggregate([record()]); - - expect(result.buckets[0]?.costUsd).toBeCloseTo(0.004625, 9); - expect(result.buckets[0]?.costSource).toBe("modelPriced"); - }); - it("counts tokens but not cost for a model with no rate", () => { const result = aggregate([record({ model: "kimi-k3" })]); @@ -268,26 +230,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costSource).toBe("providerReported"); }); - it("drops records outside the window", () => { - const result = aggregate([record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") })]); - - expect(result.outOfWindow).toBe(1); - expect(result.buckets).toHaveLength(0); - }); - - it("reports whether a record contributed", () => { - const aggregator = new UsageAggregator({ - timeZone: "UTC", - sinceDay: "2026-08-01", - untilDay: "2026-08-31", - rates, - }); - - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(true); - expect(aggregator.add(record({ dedupeKey: "msg_1:" }))).toBe(false); - expect(aggregator.add(record({ timestampMs: Date.parse("2026-07-01T12:00:00Z") }))).toBe(false); - }); - it("folds a mapped model into its target and prices it there", () => { const aggregator = new UsageAggregator({ timeZone: "UTC", @@ -306,16 +248,6 @@ describe("UsageAggregator", () => { expect(result.buckets[0]?.costUsd).toBeCloseTo(0.00925, 9); expect(result.buckets[0]?.unpricedRecords).toBe(0); }); - - it("separates providers and models into their own buckets", () => { - const result = aggregate([ - record(), - record({ provider: "codex", model: "gpt-5.6-sol" }), - record({ model: "claude-opus-5" }), - ]); - - expect(result.buckets).toHaveLength(3); - }); }); describe("resolveModelAliases", () => { diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 58a6046d39a8..231454d7c175 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { decodeScanCache, - dedupeWithinFile, encodeScanCache, makeScanCacheWriter, - pruneScanCache, type CachedFile, type ScanCache, } from "./usageScanCache.ts"; @@ -164,7 +162,7 @@ describe("scan cache round trip", () => { expect(restored).toEqual(original); }); - it.each([5, 6])( + it.each([5, 6, 7])( "retains v%s history and compatible positions but invalidates obsolete state", (version) => { const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]); @@ -174,7 +172,7 @@ describe("scan cache round trip", () => { records: [record({ provider: "codex", speed: "fast", dedupeKey: null })], position: position({ state: { - ...(version === 6 ? initialCodexScanState() : {}), + ...(version >= 6 ? initialCodexScanState() : {}), model: "gpt-6-astra", speed: "fast", sessionId: "session-a", @@ -222,7 +220,7 @@ describe("scan cache round trip", () => { position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, }); expect(restored.get("/gone-pi.jsonl")).toEqual( - version === 6 + version >= 6 ? original.get("/gone-pi.jsonl") : { ...original.get("/gone-pi.jsonl"), @@ -233,52 +231,6 @@ describe("scan cache round trip", () => { }, ); - it("drops an entry whose persisted parse state is corrupt", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { - ...encoded, - files: { - "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, - }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); - }); - - it("drops an entry whose guard length is outside the supported range", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { - ...encoded, - files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); - }); - - it("drops an entry whose speed is not a known index", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ speed: "fast" })]]])); - const row = encoded.files["/a.jsonl"]!.r[0]!; - const poisoned = { - ...encoded, - files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, - }; - - expect( - decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).has("/a.jsonl"), - ).toBe(false); - }); - - it("rejects a document from before records carried a speed", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const previous = { ...encoded, version: 3 }; - - expect(decodeScanCache(JSON.parse(JSON.stringify(previous)), TEST_FORMAT_MAP).size).toBe(0); - }); - it("rewrites only changed entries and still restores the whole cache", () => { const write = makeScanCacheWriter(); const cache = cacheWith([ @@ -300,15 +252,6 @@ describe("scan cache round trip", () => { expect(document.sources).toEqual(sources); }); - it("interns repeated model and session strings", () => { - const encoded = encodeScanCache( - cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), - ); - - expect(encoded.models).toEqual(["claude-fable-5"]); - expect(encoded.sessions).toEqual(["session-a"]); - }); - it("treats a corrupt or foreign document as an empty cache", () => { expect(decodeScanCache(null, TEST_FORMAT_MAP).size).toBe(0); expect(decodeScanCache("nonsense", TEST_FORMAT_MAP).size).toBe(0); @@ -328,13 +271,6 @@ describe("scan cache round trip", () => { expect([...restored.keys()]).toEqual(["/good.jsonl"]); }); - it("rejects the whole cache when an intern table holds a non-string", () => { - const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const poisoned = { ...encoded, models: [1] }; - - expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned)), TEST_FORMAT_MAP).size).toBe(0); - }); - it("drops the whole entry when any row is corrupt, forcing a cold re-parse", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" })]]]), @@ -354,43 +290,3 @@ describe("scan cache round trip", () => { expect(restored.has("/a.jsonl")).toBe(false); }); }); - -describe("pruneScanCache", () => { - const retentionCutoffMs = 1000; - - it("drops entries older than retention", () => { - const cache = cacheWith([["/old.jsonl", 500, [record()]]]); - - const removed = pruneScanCache(cache, retentionCutoffMs); - - expect(removed).toBe(1); - expect(cache.size).toBe(0); - }); - - it("keeps entries whose file has disappeared", () => { - const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - - pruneScanCache(cache, retentionCutoffMs); - - expect(cache.size).toBe(1); - }); -}); - -describe("dedupeWithinFile", () => { - it("keeps the first record per dedupe key", () => { - const kept = dedupeWithinFile([ - record({ totals: { ...record().totals, outputTokens: 1 } }), - record({ totals: { ...record().totals, outputTokens: 999 } }), - record({ dedupeKey: "msg_2:" }), - ]); - - expect(kept).toHaveLength(2); - expect(kept[0]?.totals.outputTokens).toBe(1); - }); - - it("keeps every record that has no dedupe key", () => { - expect( - dedupeWithinFile([record({ dedupeKey: null }), record({ dedupeKey: null })]), - ).toHaveLength(2); - }); -}); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 8ae4068f2a00..2cffb633debf 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 7 as const; +const USAGE_SCAN_CACHE_VERSION = 8 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v7.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v8.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v7.json", "usage-scan-cache-v6.json", "usage-scan-cache-v5.json", "usage-scan-cache.json", @@ -310,9 +311,9 @@ export function decodeScanCache( continue; } // Keep history for deleted rollouts, but re-parse incompatible reducer state. - // Only Codex requires new provenance state in v7. + // Older Codex entries lack provenance state (v7) or misread compaction and resume counters (v8). const legacy = - format.state !== undefined && (version < 6 || (entry.p === "codex" && version < 7)); + format.state !== undefined && (version < 6 || (entry.p === "codex" && version < 8)); // A corrupt state disqualifies the entry: resuming with it would attach // appended usage to the wrong model or replay fork-copied history. if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 79014bfc222d..c2eb20fa3066 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -297,12 +297,6 @@ describe("readTranscriptRecords resume", () => { assert.strictEqual(result.records[0]?.sessionId, "codex-session-1"); assert.strictEqual(result.position.resumeOffset, Buffer.byteLength(content)); }); - - it("returns null for an unreadable file", async () => { - assert.isNull( - await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), TEST_FORMATS.claude), - ); - }); }); describe("transcriptLineGate", () => { @@ -312,17 +306,6 @@ describe("transcriptLineGate", () => { }; const markers = { one: ['"usage"'], several: ['"token_count"', '"session_meta"', '"a.b(c)"'] }; - it("passes only lines containing one of its markers", () => { - for (const kind of ["one", "several"] as const) { - for (const marker of markers[kind]) { - assert.isTrue(gates[kind](Buffer.from(`{"payload":{${marker}:1}}`))); - assert.isFalse(gates[kind](Buffer.from(`{"text":"see ${marker.slice(1, -1)} here"}`))); - assert.isFalse(gates[kind](Buffer.from(`{${marker.replace(/[._]|usage/, "x")}:1}`))); - } - assert.isFalse(gates[kind](Buffer.alloc(0))); - } - }); - it("matches markers beside multi-byte and malformed UTF-8", () => { const invalid = Buffer.from([0xff, 0xc3]); for (const kind of ["one", "several"] as const) { diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index d8dfaa27b626..3a36389ffb70 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -12,7 +12,6 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/process"; import { assert, it } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; @@ -443,47 +442,6 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint recovery refuses excessive candidates before probing", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-cap-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["init", "empty0"]); - for (let i = 1; i < 65; i++) - yield* fs.copy(path.join(cwd, "empty0"), path.join(cwd, `empty${i}`)); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - let stageError: VcsProcessExitError | undefined; - let nestedProbes = 0; - let stageAttempts = 0; - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - if (input.cwd !== cwd && input.args.includes("rev-parse")) nestedProbes++; - if (input.args.includes("add") && input.args.includes("-A")) stageAttempts++; - return liveProcess.run(input).pipe( - Effect.tapError((error) => { - if (error._tag === "VcsProcessExitError") stageError = error; - return Effect.void; - }), - ); - }, - }), - ); - const result = yield* Effect.result( - captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }), - ); - assert.strictEqual(nestedProbes, 0); - assert.strictEqual(stageAttempts, 1); - assert.strictEqual(result._tag, "Failure"); - if (result._tag === "Failure") assert.strictEqual(result.failure, stageError); - assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect.each([ { phase: "add", nestedRecovery: false, expireRecovery: false }, { phase: "update-ref", nestedRecovery: false, expireRecovery: false }, @@ -590,95 +548,6 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(layerGitCaptureContract)), ); -it.effect.each(["discovery", "probe", "retry"] as const)( - "checkpoint recovery has one deadline including %s", - (blockedPhase) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-recovery-timeout-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["init", "empty"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index")); - const entered = yield* Deferred.make(); - const discovered = yield* Deferred.make(); - let stageError: VcsProcessExitError | undefined; - let privateIndex: string | undefined; - let interrupted = false; - let stagingAttempts = 0; - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - const staging = input.args.includes("add") && input.args.includes("-A"); - if (staging) { - privateIndex = input.env?.GIT_INDEX_FILE; - stagingAttempts += 1; - } - const block = - (blockedPhase === "discovery" && input.args.includes("--others")) || - (blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) || - (blockedPhase === "retry" && - staging && - input.args.some((arg) => arg.startsWith(":(exclude,literal)"))); - if (block) - return ( - blockedPhase === "retry" - ? fs - .writeFileString( - `${input.env!.GIT_INDEX_FILE!}.lock`, - "interrupted index write", - ) - .pipe(Effect.orDie) - : Effect.void - ).pipe( - Effect.andThen(Deferred.succeed(entered, undefined)), - Effect.andThen(Effect.never), - Effect.onInterrupt(() => - Effect.sync(() => { - interrupted = true; - }), - ), - ); - return liveProcess.run(input).pipe( - Effect.tap(() => - blockedPhase === "probe" && input.args.includes("--others") - ? Deferred.succeed(discovered, undefined).pipe( - Effect.andThen(Effect.sleep("3 seconds")), - ) - : Effect.void, - ), - Effect.tapError((error) => { - if (staging && error._tag === "VcsProcessExitError") stageError = error; - return Effect.void; - }), - ); - }, - }), - ); - const captureStore = yield* makeCaptureStore(captureDriver, cwd); - const fiber = yield* captureStore - .captureCheckpoint({ cwd, checkpointRef }) - .pipe(Effect.flip, Effect.forkScoped); - if (blockedPhase === "probe") { - yield* Deferred.await(discovered); - yield* TestClock.adjust("3 seconds"); - } - yield* Deferred.await(entered); - yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds"); - const error = yield* Fiber.join(fiber); - assert.strictEqual(error, stageError); - assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1); - assert.isTrue(interrupted); - assert.isDefined(privateIndex); - assert.isFalse(yield* fs.exists(privateIndex!)); - assert.isFalse(yield* fs.exists(`${privateIndex!}.lock`)); - assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect("checkpoint recovery preserves interruption and removes the private index", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -713,39 +582,6 @@ it.effect("checkpoint recovery preserves interruption and removes the private in }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* fileSystem.writeFileString( - path.join(cwd, ".gitattributes"), - "stable.txt filter=probe\n", - ); - yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); - yield* fileSystem.writeFileString( - path.join(cwd, ".git", "filter.cjs"), - 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', - ); - yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); - yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); - yield* git(["add", "."]); - yield* git(["commit", "-m", "record stable file"]); - yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); - yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); - const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); - - yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - - assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); - assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); - assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect.each( [false, true].flatMap((nested) => (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( @@ -844,46 +680,6 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("checkpoint capture keeps the legacy path when Git lacks add --sparse", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-legacy-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - yield* git(["sparse-checkout", "set", "--cone", "included"]); - const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); - const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - if (input.args.includes("-h")) - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(129), - stdout: "usage: git add", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - return liveProcess.run( - input.args.includes("--sparse") - ? { - ...input, - args: input.args.map((arg) => - arg === "--sparse" ? "--unsupported-sparse" : arg, - ), - } - : input, - ); - }, - }), - ); - yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); - assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); - it.effect.each(["normal", "flags", "sparse"] as const)( "checkpoint index inspection handles entries beyond the output cap (index=%s)", (indexMode) => @@ -967,103 +763,6 @@ it.effect.each([1_700_000_000, 1_700_000_000.9999])( assert.deepEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each( - [false, true].flatMap((nested) => - (["sparse", "flags", "manual-skip", "missing", "non-cone-missing"] as const).map( - (indexState) => ({ nested, indexState }), - ), - ), -)( - "sparse checkpoint preserves two captures (nested=$nested, index=$indexState)", - ({ nested, indexState }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" }); - const { git } = yield* makeCheckpointFixture(driver, cwd); - const write = Effect.fn(function* (name: string, contents: string) { - yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); - yield* fs.writeFileString(path.join(cwd, name), contents); - }); - for (const name of [ - "scope/in/edit", - "scope/in/delete", - "scope/out/deep/absent", - "scope/out/present", - "elsewhere/file", - ]) { - yield* write(name, "original\n"); - } - yield* git(["add", "."]); - yield* git(["commit", "-m", "sparse fixture"]); - yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "scope/in", "elsewhere"]); - if (indexState === "non-cone-missing") - yield* git(["sparse-checkout", "set", "--no-cone", "/scope/in/", "/elsewhere/"]); - yield* write("scope/in/edit", "staged\n"); - yield* write("elsewhere/file", "staged outside\n"); - yield* git(["add", "."]); - if (indexState === "flags") - yield* git(["update-index", "--assume-unchanged", "scope/in/delete"]); - if (indexState === "manual-skip") - yield* git(["update-index", "--skip-worktree", "scope/in/delete"]); - yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]); - yield* write("scope/in/edit", "working\n"); - yield* write("scope/out/present", "modified skipped\n"); - yield* write("scope/out/new file", "new outside cone\n"); - yield* write("elsewhere/file", "working outside\n"); - yield* fs.remove(path.join(cwd, "scope/in/delete")); - const indexPath = path.join(cwd, ".git/index"); - if (indexState.endsWith("missing")) yield* fs.remove(indexPath); - const originalIndex = yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)); - const captureCwd = nested ? path.join(cwd, "scope") : cwd; - for (const turn of [1, 2]) { - const ref = CheckpointRef.make(`refs/t3/checkpoints/sparse/${turn}`); - if (turn === 2) { - yield* write("scope/in/edit", "second\n"); - yield* fs.remove(path.join(cwd, "scope/out/new file")); - yield* write("scope/out/second", "second addition\n"); - } - const capture = driver.checkpoints.captureCheckpoint({ - cwd: captureCwd, - checkpointRef: ref, - }); - if (indexState === "non-cone-missing") { - assert.strictEqual((yield* capture.pipe(Effect.flip))._tag, "VcsProcessExitError"); - assert.isFalse( - yield* driver.checkpoints.hasCheckpointRef({ cwd: captureCwd, checkpointRef: ref }), - ); - assert.isFalse(yield* fs.exists(indexPath)); - break; - } - yield* capture; - for (const [name, content] of [ - ["scope/out/deep/absent", "original\n"], - ["scope/out/present", "modified skipped\n"], - ["scope/in/edit", turn === 1 ? "working\n" : "second\n"], - ["elsewhere/file", nested ? "original\n" : "working outside\n"], - [ - turn === 1 ? "scope/out/new file" : "scope/out/second", - turn === 1 ? "new outside cone\n" : "second addition\n", - ], - ]) { - assert.strictEqual((yield* git(["show", `${ref}:${name}`])).stdout, content); - } - const files = (yield* git(["ls-tree", "-rz", "--name-only", ref])).stdout.split("\0"); - assert.notInclude(files, "scope/in/delete"); - if (turn === 2) assert.notInclude(files, "scope/out/new file"); - assert.deepEqual( - yield* fs.readFile(indexPath).pipe(Effect.orElseSucceed(() => null)), - originalIndex, - ); - assert.isFalse(yield* fs.exists(path.join(cwd, "scope/out/deep/absent"))); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "elsewhere/file")), - "working outside\n", - ); - } - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); it.effect("checkpoint capture preserves racy edits made after resetting the index", () => Effect.gen(function* () { @@ -1278,54 +977,6 @@ it.effect("restores empty checkpoints without changing paths outside the workspa }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect("GitVcsDriver forwards execute env to the VCS process", () => { - let observedEnv: NodeJS.ProcessEnv | undefined; - let observedAppendTruncationMarker: boolean | undefined; - let observedOutputMode: VcsProcess.VcsProcessInput["outputMode"]; - - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - - yield* driver.execute({ - operation: "GitVcsDriver.test.env", - cwd: "/repo", - args: ["status"], - env: { - GIT_INDEX_FILE: "/tmp/t3-index", - }, - appendTruncationMarker: true, - outputMode: "error", - }); - - assert.deepStrictEqual(observedEnv, { - GIT_INDEX_FILE: "/tmp/t3-index", - }); - assert.strictEqual(observedAppendTruncationMarker, true); - assert.strictEqual(observedOutputMode, "error"); - }).pipe( - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - observedEnv = input.env; - observedAppendTruncationMarker = input.appendTruncationMarker; - observedOutputMode = input.outputMode; - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - ), - ), - ); -}); - // The real VCS process, spawning `command` wherever the driver runs git. const vcsProcessSpawning = (command: string) => Layer.effect( @@ -1366,128 +1017,3 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( assert.strictEqual(detectionError._tag, "VcsProcessSpawnError"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - -it.effect.each([ - { platform: "linux", fsyncMethods: ["fsync"] }, - { platform: "darwin", fsyncMethods: ["fsync", "batch"] }, - { platform: "win32", fsyncMethods: ["fsync", "batch"] }, -] as const)( - "GitVcsDriver flushes checkpoint objects and refs to disk before publishing them on $platform", - ({ platform, fsyncMethods }) => { - const observedArgs: ReadonlyArray[] = []; - - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - - yield* driver.checkpoints.captureCheckpoint({ - cwd: "/repo", - checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread/turn/1"), - }); - - const writeCommands = ["add", "write-tree", "commit-tree", "update-ref"]; - const writes = observedArgs.filter((args) => - writeCommands.some((command) => args.includes(command)), - ); - assert.strictEqual(writes.length, 4); - for (const args of writes) { - const command = args.findIndex((arg) => writeCommands.includes(arg)); - assert.deepStrictEqual( - args.filter((arg) => arg.startsWith("core.fsyncMethod=")), - fsyncMethods.map((method) => `core.fsyncMethod=${method}`), - ); - for (const setting of [ - "core.fsync=objects,reference", - ...fsyncMethods.map((method) => `core.fsyncMethod=${method}`), - ]) { - const index = args.indexOf(setting); - assert.strictEqual(args[index - 1], "-c", args.join(" ")); - assert.isBelow(index, command); - } - } - assert.deepStrictEqual(observedArgs.at(-1), [ - "-C", - "/repo", - "-c", - "core.fsync=objects,reference", - ...fsyncMethods.flatMap((method) => ["-c", `core.fsyncMethod=${method}`]), - "update-ref", - "refs/t3/checkpoints/thread/turn/1", - "commit0000", - ]); - }).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provide( - Layer.mergeAll( - NodeServices.layer, - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - observedArgs.push(input.args); - const stdout = input.args.includes("write-tree") - ? "tree0000\n" - : input.args.includes("commit-tree") - ? "commit0000\n" - : input.args.includes("--git-common-dir") - ? ".git\n" - : ""; - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; - }), - }), - ), - ), - ); - }, -); - -it.effect.each(["win32", "linux"] as const)( - "checkpoint working-tree commands enable Git long paths only on Windows (%s)", - (platform) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const liveProcess = yield* VcsProcess.VcsProcess; - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-long-paths-" }); - const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); - // An unborn nested repository fails the first `add`, so recovery's listing runs too. - yield* git(["init", "empty"]); - const observedArgs: ReadonlyArray[] = []; - const platformDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(VcsProcess.VcsProcess, { - run: (input) => { - observedArgs.push(input.args); - return liveProcess.run(input); - }, - }), - ); - - yield* platformDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); - yield* fs.writeFileString(path.join(cwd, "file.txt"), "changed\n"); - assert.isTrue(yield* platformDriver.checkpoints.restoreCheckpoint({ cwd, checkpointRef })); - - assert.strictEqual(yield* fs.readFileString(path.join(cwd, "file.txt")), "unstaged\n"); - assert.strictEqual((yield* git(["ls-tree", checkpointRef, "--", "empty"])).stdout, ""); - for (const command of ["add", "ls-files", "restore", "clean", "reset"]) { - const calls = observedArgs.filter( - (args) => args.includes(command) && (command !== "ls-files" || args.includes("--others")), - ); - assert.isNotEmpty(calls, `Expected checkpoint command: ${command}`); - for (const args of calls) { - const setting = args.indexOf("core.longpaths=true"); - if (platform === "win32") { - assert.strictEqual(args[setting - 1], "-c", args.join(" ")); - assert.isBelow(setting, args.indexOf(command), args.join(" ")); - } else { - assert.strictEqual(setting, -1, args.join(" ")); - } - } - } - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), -); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 71b8b42a2d21..c1773184def9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,25 +2,20 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Metric from "effect/Metric"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { @@ -34,7 +29,6 @@ import { } from "@t3tools/contracts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as ServerConfig from "../config.ts"; -import { gitCommandDuration } from "../observability/Metrics.ts"; import { makeGitVcsDriverCore, parseGitCheckoutProgressLine, @@ -43,7 +37,6 @@ import { } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { gitConfigEnv } from "./VcsProcess.ts"; -import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); const decodeVcsRef = Schema.decodeUnknownSync(VcsRef); @@ -158,112 +151,6 @@ const initRepoWithCommit = ( return { initialBranch }; }); -it.effect("bounds Git bursts across drivers without timing out queued commands", () => - Effect.gen(function* () { - const gate = yield* Deferred.make(); - const starts = yield* Queue.unbounded(); - let active = 0; - let peak = 0; - const spawner = ChildProcessSpawner.make(() => - Effect.acquireRelease( - Effect.gen(function* () { - peak = Math.max(peak, ++active); - yield* Queue.offer(starts, active); - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle("ok"), - exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - () => Effect.sync(() => active--), - ), - ); - const drivers = yield* Effect.all( - Array.from({ length: 16 }, () => - makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ); - const burst = yield* Effect.forEach( - drivers, - (driver, index) => - driver.execute({ - operation: "test.gitBurst", - cwd: "/repo", - args: ["rev-parse", "HEAD"], - ...(index < 4 ? {} : { timeoutMs: index < 8 ? 30_000 : 1_000 }), - }), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - - yield* TestClock.adjust("2 seconds"); - assert.equal(yield* Queue.size(starts), 8); - assert.equal(peak, 8); - yield* Deferred.succeed(gate, undefined); - const results = yield* Fiber.join(burst); - assert.equal(results.length, 16); - assert.isTrue(results.every((result) => result.stdout === "ok" && result.exitCode === 0)); - assert.equal(peak, 8); - assert.equal(active, 0); - const duration = yield* Metric.value( - Metric.withAttributes(gitCommandDuration, [["operation", "test.gitBurst"]]), - ); - assert.equal(duration.count, 16); - assert.equal(duration.sum, 16_000); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - -it.effect.each([{ timeoutMs: null }, { timeoutMs: 30_001 }])( - "keeps all Git slots available with a pending command whose timeout is $timeoutMs", - ({ timeoutMs }) => - Effect.gen(function* () { - const slowGate = yield* Deferred.make(); - const fastGate = yield* Deferred.make(); - const starts = yield* Queue.unbounded(); - let active = 0; - const spawner = ChildProcessSpawner.make((command) => - Effect.acquireRelease( - Effect.gen(function* () { - active++; - yield* Queue.offer(starts, undefined); - const gate = - ChildProcess.isStandardCommand(command) && command.args[0] === "push" - ? slowGate - : fastGate; - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle("ok"), - exitCode: Deferred.await(gate).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - () => Effect.sync(() => active--), - ), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const slow = yield* driver - .execute({ operation: "test.slowGit", cwd: "/repo", args: ["push"], timeoutMs }) - .pipe(Effect.forkChild); - yield* Queue.take(starts); - const burst = yield* Effect.all( - Array.from({ length: 8 }, () => - driver.execute({ operation: "test.fastGit", cwd: "/repo", args: ["status"] }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - - yield* TestClock.adjust("0 seconds"); - assert.equal(yield* Queue.size(starts), 8); - assert.equal(active, 9); - yield* Deferred.succeed(fastGate, undefined); - assert.equal((yield* Fiber.join(burst)).length, 8); - assert.equal(active, 1); - yield* Deferred.succeed(slowGate, undefined); - assert.equal((yield* Fiber.join(slow)).stdout, "ok"); - assert.equal(active, 0); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - it.effect.each(["root", "nested", "worktree"] as const)( "skips clean filters while the %s index is locked and resumes after unlock", (location) => @@ -307,46 +194,6 @@ it.effect.each(["root", "nested", "worktree"] as const)( }).pipe(Effect.provide(layerTest)), ); -it.effect("uses stable diagnostics for every parsed non-repository command", () => { - const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.sync(() => { - if (!ChildProcess.isStandardCommand(command)) { - return assert.fail("expected a standard Git command"); - } - commands.push({ - args: command.args, - ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), - }); - return makeNonRepositoryHandle(); - }), - ); - const layerNodeServices = Layer.merge( - NodeServices.layer, - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const layer = GitVcsDriver.layer.pipe( - Layer.provide(layerServerConfig), - Layer.provideMerge(layerNodeServices), - ); - - return Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = "/repo"; - - yield* driver.statusDetailsLocal(cwd); - yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); - yield* driver.listRefs({ cwd }); - - assert.deepStrictEqual(commands, [ - { args: ["rev-parse", "--git-path", "index"], lcAll: "C" }, - { args: ["status", "--porcelain=2", "--branch", "-z"], lcAll: "C" }, - { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, - ]); - }).pipe(Effect.provide(layer)); -}); - it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; @@ -365,387 +212,7 @@ it.effect("invalidates origin remote cache when a driver mutation adds origin", }).pipe(Effect.provide(layerTest)), ); -it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); - - yield* git(cwd, ["remote", "add", "origin", remote]); - - const stillCached = yield* driver.statusDetailsLocal(cwd); - assert.equal(stillCached.hasOriginRemote, false); - - yield* TestClock.adjust("6 minutes"); - - const afterExpiry = yield* driver.statusDetailsLocal(cwd); - assert.equal(afterExpiry.hasOriginRemote, true); - }).pipe(Effect.provide(layerTest)), -); - -it.effect("coalesces concurrent ref pages into one repository snapshot", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const spawnedArgs = yield* Ref.make>>([]); - const firstWorktreeScanStarted = yield* Deferred.make(); - const remoteNamesScanCompleted = yield* Deferred.make(); - const delayFirstWorktreeScan = yield* Ref.make(true); - const countingSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); - const isWorktreeScan = - command.args.includes("worktree") && command.args.includes("--porcelain"); - const shouldDelay = - isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); - if (shouldDelay) { - yield* Deferred.succeed(firstWorktreeScanStarted, undefined); - yield* Effect.sleep("8 seconds"); - } - const handle = yield* delegate.spawn(command); - const isRemoteNamesScan = - command.args.length === 3 && - command.args[0] === "--git-dir" && - command.args[2] === "remote"; - return isRemoteNamesScan - ? ChildProcessSpawner.makeHandle({ - ...handle, - exitCode: handle.exitCode.pipe( - Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), - ), - }) - : handle; - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), - ); - const cwd = yield* makeTmpDir(); - const runGit = (args: ReadonlyArray) => - driver.execute({ - operation: "GitVcsDriver.test.coalescedListRefs", - cwd, - args, - timeoutMs: 10_000, - }); - - yield* driver.initRepo({ cwd }); - yield* runGit(["config", "user.email", "test@test.com"]); - yield* runGit(["config", "user.name", "Test"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* runGit(["add", "."]); - yield* runGit(["commit", "-m", "initial commit"]); - yield* Ref.set(spawnedArgs, []); - - const initialRequest = yield* driver - .listRefs({ cwd, refresh: true, limit: 100 }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(firstWorktreeScanStarted); - yield* Deferred.await(remoteNamesScanCompleted); - yield* TestClock.adjust("6 seconds"); - const laterRequests = yield* Effect.all( - Array.from({ length: 30 }, (_, index) => - driver.listRefs({ - cwd, - refresh: true, - query: `missing-${index}`, - limit: 100, - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild({ startImmediately: true })); - yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(initialRequest); - yield* Fiber.join(laterRequests); - yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); - - const firstSnapshotCommands = yield* Ref.get(spawnedArgs); - const snapshotRefScans = firstSnapshotCommands.filter( - (args) => - args.includes("for-each-ref") && - args.includes("refs/heads") && - args.includes("refs/remotes"), - ); - const worktreeScans = firstSnapshotCommands.filter( - (args) => args.includes("worktree") && args.includes("--porcelain"), - ); - assert.equal(snapshotRefScans.length, 1); - assert.equal(worktreeScans.length, 1); - - yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); - const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); - assert.equal( - refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), - true, - ); - const allCommands = yield* Ref.get(spawnedArgs); - assert.equal( - allCommands.filter( - (args) => - args.includes("for-each-ref") && - args.includes("refs/heads") && - args.includes("refs/remotes"), - ).length, - 2, - ); - }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - describe("pullCurrentBranch", () => { - const controlledStatusFetch = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* makeTmpDir(); - yield* fs.makeDirectory(`${cwd}/.git`); - const started = yield* Queue.unbounded<{ - cwd: string; - release: Deferred.Deferred; - }>(); - const finalized = yield* Queue.unbounded(); - const warnings = yield* Queue.unbounded(); - const attempts = yield* Ref.make(0); - const pulls = yield* Ref.make(0); - const fetching = yield* Ref.make(false); - const head = yield* Ref.make("before"); - const pullRemoteRead = yield* Deferred.make(); - const logger = Logger.make(() => { - Queue.offerUnsafe(warnings, undefined); - }); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) return makeNonRepositoryHandle(); - const workingDirectory = command.options.cwd ?? cwd; - const args = command.args; - if (args.includes("fetch")) { - const release = yield* Deferred.make(); - yield* Ref.set(fetching, true); - yield* Ref.update(attempts, (count) => count + 1); - yield* Queue.offer(started, { cwd: workingDirectory, release }); - yield* Effect.addFinalizer(() => Queue.offer(finalized, workingDirectory)); - yield* Effect.addFinalizer(() => Ref.set(fetching, false)); - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle(""), - stderr: Stream.encodeText(Stream.make("fatal: remote unavailable\n")), - exitCode: Deferred.await(release), - }); - } - if (args[0] === "pull") { - yield* Ref.update(pulls, (count) => count + 1); - if (yield* Ref.get(fetching)) { - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle(""), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - stderr: Stream.encodeText( - Stream.make("error: cannot lock ref 'refs/remotes/origin/main': File exists\n"), - ), - }); - } - yield* Ref.set(head, "after"); - return makeSuccessfulHandle(""); - } - if (args.includes("branch.main.remote")) { - yield* Deferred.succeed(pullRemoteRead, undefined); - return makeSuccessfulHandle("origin\n"); - } - if (args[0] === "status") - return makeSuccessfulHandle( - "# branch.oid before\0# branch.head main\0# branch.upstream origin/main\0# branch.ab +0 -1\0", - ); - if (args[0] === "diff-index") return makeSuccessfulHandle(""); - if (args.length === 2 && args[0] === "rev-parse" && args[1] === "HEAD") - return makeSuccessfulHandle(`${yield* Ref.get(head)}\n`); - if (args.includes("--git-common-dir")) - return makeSuccessfulHandle(`${workingDirectory}/.git\n`); - if (args.includes("--show-toplevel")) return makeSuccessfulHandle(`${workingDirectory}\n`); - if (args.includes("@{upstream}")) return makeSuccessfulHandle("origin/main\n"); - if (args[0] === "remote") return makeSuccessfulHandle("origin\n"); - if (args[0] === "rev-list") return makeSuccessfulHandle("0\t0\n"); - if (args.includes("refs/remotes/origin/HEAD")) - return makeSuccessfulHandle("refs/remotes/origin/main\n"); - return makeSuccessfulHandle("main\n"); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const status = Effect.fn("test.controlledStatusFetch.status")((directory = cwd) => - driver - .statusDetailsRemote(directory) - .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))), - ); - return { cwd, driver, status, started, finalized, warnings, attempts, pulls, pullRemoteRead }; - }); - - it.effect.each([0, 128])( - "waits for an existing status fetch before pulling after fetch exit %s", - (exitCode) => - Effect.gen(function* () { - const fixture = yield* controlledStatusFetch; - const status = yield* fixture.status().pipe(Effect.forkScoped); - const fetch = yield* Queue.take(fixture.started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(status); - - const pull = yield* fixture.driver.pullCurrentBranch(fixture.cwd).pipe(Effect.forkScoped); - yield* Deferred.await(fixture.pullRemoteRead); - yield* TestClock.adjust("30 seconds"); - assert.equal(yield* Ref.get(fixture.pulls), 0); - yield* Deferred.succeed(fetch.release, ChildProcessSpawner.ExitCode(exitCode)); - - assert.equal((yield* Fiber.join(pull)).status, "pulled"); - assert.equal(yield* Ref.get(fixture.pulls), 1); - assert.equal(yield* Ref.get(fixture.attempts), 1); - }).pipe( - Effect.scoped, - Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer))), - ), - ); - - it.effect("does not start a status fetch while pulling", () => - Effect.gen(function* () { - const fixture = yield* controlledStatusFetch; - - assert.equal((yield* fixture.driver.pullCurrentBranch(fixture.cwd)).status, "pulled"); - assert.equal(yield* Ref.get(fixture.pulls), 1); - assert.equal(yield* Ref.get(fixture.attempts), 0); - }).pipe( - Effect.scoped, - Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer))), - ), - ); - - it.effect("backs off failures after the status waiter has returned", () => - Effect.gen(function* () { - const fixture = yield* controlledStatusFetch; - const firstStatus = yield* fixture.status().pipe(Effect.forkScoped); - const first = yield* Queue.take(fixture.started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(firstStatus); - yield* Deferred.succeed(first.release, ChildProcessSpawner.ExitCode(128)); - yield* Queue.take(fixture.finalized); - yield* Queue.take(fixture.warnings); - - yield* TestClock.adjust("15 seconds"); - yield* fixture.status(); - assert.equal(yield* Ref.get(fixture.attempts), 1); - - yield* TestClock.adjust("15 seconds"); - const secondStatus = yield* fixture.status().pipe(Effect.forkScoped); - const second = yield* Queue.take(fixture.started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(secondStatus); - yield* Deferred.succeed(second.release, ChildProcessSpawner.ExitCode(128)); - yield* Queue.take(fixture.finalized); - yield* Queue.take(fixture.warnings); - - for (let i = 0; i < 3; i++) { - yield* TestClock.adjust("15 seconds"); - yield* fixture.status(); - assert.equal(yield* Ref.get(fixture.attempts), 2); - } - yield* TestClock.adjust("15 seconds"); - const recoveredStatus = yield* fixture.status().pipe(Effect.forkScoped); - const recovered = yield* Queue.take(fixture.started); - yield* Deferred.succeed(recovered.release, ChildProcessSpawner.ExitCode(0)); - yield* Fiber.join(recoveredStatus); - assert.equal(yield* Ref.get(fixture.attempts), 3); - - yield* TestClock.adjust("15 seconds"); - const failedAgainStatus = yield* fixture.status().pipe(Effect.forkScoped); - const failedAgain = yield* Queue.take(fixture.started); - yield* Deferred.succeed(failedAgain.release, ChildProcessSpawner.ExitCode(128)); - yield* Fiber.join(failedAgainStatus); - yield* Queue.take(fixture.warnings); - yield* TestClock.adjust("15 seconds"); - yield* fixture.status(); - assert.equal(yield* Ref.get(fixture.attempts), 4); - - yield* TestClock.adjust("15 seconds"); - const resetStatus = yield* fixture.status().pipe(Effect.forkScoped); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(resetStatus); - assert.equal(yield* Ref.get(fixture.attempts), 5); - const reset = yield* Queue.take(fixture.started); - yield* Deferred.succeed(reset.release, ChildProcessSpawner.ExitCode(0)); - }).pipe( - Effect.scoped, - Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer))), - ), - ); - - it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( - "bounds cleanup when a Git subprocess ignores SIGTERM", - () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const directory = yield* makeTmpDir(); - const probe = yield* makeProcessProbe; - writeFakeCli({ - directory, - name: "git", - source: `process.on("SIGTERM", () => {}); ${processProbeSource(probe.port)}`, - }); - const run = yield* driver - .execute({ - operation: "test.stalledStatusFetch", - cwd: directory, - args: ["fetch"], - env: { PATH: `${directory}:${process.env.PATH ?? ""}` }, - timeoutMs: 1_000, - forceKillAfterMs: 100, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* probe.started; - - yield* TestClock.adjust("1 second"); - - yield* probe.exited; - assert.equal((yield* Fiber.join(run)).detail, "Git command timed out."); - }).pipe(Effect.scoped, Effect.provide(layerTest)), - ); - - it.effect("releases stalled background worker slots at their lifetime limit", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const fixture = yield* controlledStatusFetch; - yield* fixture.driver.listRefs({ cwd: fixture.cwd }); - const statuses = []; - for (let i = 0; i < 4; i++) { - const cwd = `${fixture.cwd}/repo-${i}`; - yield* fs.makeDirectory(`${cwd}/.git`, { recursive: true }); - statuses.push(yield* fixture.status(cwd).pipe(Effect.forkScoped)); - yield* Queue.take(fixture.started); - } - yield* TestClock.adjust("5 seconds"); - yield* Effect.forEach(statuses, Fiber.join); - const waiting = yield* fixture.status().pipe(Effect.forkScoped); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(waiting); - assert.equal(yield* Ref.get(fixture.attempts), 4); - - yield* TestClock.adjust(290_000); - assert.equal(yield* Ref.get(fixture.attempts), 5); - for (let i = 0; i < 4; i++) yield* Queue.take(fixture.finalized); - const healthy = yield* Queue.take(fixture.started); - assert.equal( - NodeFS.realpathSync.native(healthy.cwd), - NodeFS.realpathSync.native(fixture.cwd), - ); - yield* Deferred.succeed(healthy.release, ChildProcessSpawner.ExitCode(0)); - yield* Queue.take(fixture.finalized); - }).pipe( - Effect.scoped, - Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer))), - ), - ); - // A fast-forward that prints over 1 MB takes thousands of files, so the real pull's output is // padded past the cap instead. const withPaddedPull = ( @@ -907,119 +374,6 @@ describe("pullCurrentBranch", () => { }).pipe(Effect.scoped, Effect.provide(layerServerConfig.pipe(Layer.provideMerge(layerTest)))), ); - it.effect.each(["deadline", "subscriber cancellation"] as const)( - "lets a background fetch finish after %s without leaving its lock behind", - (interruption) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const { cwd } = yield* cloneBehindRemote; - const lockPath = `${cwd}/.git/refs/remotes/origin/in-flight.lock`; - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const finalized = yield* Deferred.make(); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command) || !command.args.includes("fetch")) { - return yield* delegate.spawn(command); - } - // Model Git's gap between creating a lock and registering signal cleanup. - yield* fs.writeFileString(lockPath, ""); - yield* Effect.addFinalizer(() => Deferred.succeed(finalized, undefined)); - yield* Deferred.succeed(started, undefined); - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle(""), - exitCode: Deferred.await(release).pipe( - Effect.andThen(fs.remove(lockPath)), - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const status = yield* driver.statusDetailsRemote(cwd).pipe(Effect.forkScoped); - yield* Deferred.await(started); - - if (interruption === "deadline") { - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(status); - } else { - yield* Fiber.interrupt(status); - } - - assert.isFalse(yield* Deferred.isDone(finalized)); - assert.isTrue(yield* fs.exists(lockPath)); - - yield* Deferred.succeed(release, undefined); - yield* Deferred.await(finalized); - assert.isFalse(yield* fs.exists(lockPath)); - }).pipe(Effect.scoped, Effect.provide(layerServerConfig.pipe(Layer.provideMerge(layerTest)))), - ); - - it.effect("reuses a background fetch across status wait deadlines", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* makeTmpDir(); - yield* fs.makeDirectory(`${cwd}/.git`); - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - const finalized = yield* Deferred.make(); - const attempts = yield* Ref.make(0); - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) return makeNonRepositoryHandle(); - if (command.args.includes("fetch")) { - yield* Ref.update(attempts, (count) => count + 1); - yield* Deferred.succeed(started, undefined); - yield* Effect.addFinalizer(() => Deferred.succeed(finalized, undefined)); - return ChildProcessSpawner.makeHandle({ - ...makeSuccessfulHandle(""), - exitCode: Deferred.await(release).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - } - const args = command.args; - return makeSuccessfulHandle( - args.includes("--git-common-dir") - ? `${cwd}/.git\n` - : args.includes("--show-toplevel") - ? `${cwd}\n` - : args.includes("@{upstream}") - ? "origin/main\n" - : args[0] === "remote" - ? "origin\n" - : args[0] === "rev-list" - ? "0\t0\n" - : args.includes("refs/remotes/origin/HEAD") - ? "refs/remotes/origin/main\n" - : "main\n", - ); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const first = yield* driver.statusDetailsRemote(cwd).pipe(Effect.forkScoped); - yield* Deferred.await(started); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(first); - - yield* TestClock.adjust("15 seconds"); - const second = yield* driver.statusDetailsRemote(cwd).pipe(Effect.forkScoped); - yield* TestClock.adjust("5 seconds"); - yield* Fiber.join(second); - - assert.equal(yield* Ref.get(attempts), 1); - assert.isFalse(yield* Deferred.isDone(finalized)); - yield* Deferred.succeed(release, undefined); - yield* Deferred.await(finalized); - }).pipe( - Effect.scoped, - Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer))), - ), - ); - it.effect.each(["stdout", "stderr"] as const)( "reports a fast-forward whose %s passes the cap as pulled", (stream) => @@ -1152,42 +506,6 @@ it.effect("invalidates a ref snapshot when a mutation fails after changing Git", ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const snapshotAttempts = yield* Ref.make(0); - const failingSnapshotSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - if (command.args.includes("for-each-ref")) { - yield* Ref.update(snapshotAttempts, (count) => count + 1); - return makeNonRepositoryHandle(); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), - ); - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); - - const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.listRefs.snapshotRefs", - detail: "Git ref snapshot enumeration failed.", - exitCode: 128, - }); - assert.equal(yield* Ref.get(snapshotAttempts), 1); - }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -1332,132 +650,6 @@ it.effect("does not report a separate git dir as the worktree of its branch", () ).pipe(Effect.provide(layerTest)), ); -it.effect("refreshes the current branch after an external checkout", () => - Effect.scoped( - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "external-checkout"]); - - const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); - assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); - - yield* driver.execute({ - operation: "GitVcsDriver.test.externalCheckout", - cwd, - args: ["checkout", "external-checkout"], - timeoutMs: 10_000, - }); - yield* TestClock.adjust("6 seconds"); - - const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); - assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); - assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); - }), - ).pipe(Effect.provide(layerTest)), -); - -it.effect("backs off and logs failed fetch attempts across linked worktrees", () => - Effect.scoped( - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const fetchAttempts = yield* Ref.make(0); - const failingFetchSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - if (command.args.includes("fetch") && command.args.includes("--quiet")) { - yield* Ref.update(fetchAttempts, (count) => count + 1); - return makeNonRepositoryHandle(); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), - ); - const warnings: string[] = []; - const logger = Logger.make(({ message }) => { - warnings.push(String(message)); - }); - const readRemoteStatus = (workingDirectory: string) => - driver - .statusDetailsRemote(workingDirectory) - .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger]))); - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); - const pathService = yield* Path.Path; - const worktreePath = pathService.join(worktreesRoot, "linked"); - const runGit = (workingDirectory: string, args: ReadonlyArray) => - driver.execute({ - operation: "GitVcsDriver.test.upstreamRefreshBackoff", - cwd: workingDirectory, - args, - timeoutMs: 10_000, - }); - - yield* driver.initRepo({ cwd }); - yield* runGit(cwd, ["config", "user.email", "test@test.com"]); - yield* runGit(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "initial commit"]); - const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); - yield* runGit(remote, ["init", "--bare"]); - yield* runGit(cwd, ["remote", "add", "origin", remote]); - yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); - yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); - yield* runGit(worktreePath, [ - "branch", - "--set-upstream-to", - `origin/${initialBranch}`, - "feature/linked", - ]); - const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); - const linkedCommonDir = (yield* runGit(worktreePath, [ - "rev-parse", - "--git-common-dir", - ])).stdout.trim(); - // Native realpath, since git reports the long form of a directory the - // temp dir may name by its 8.3 short form on Windows. - assert.equal( - NodeFS.realpathSync.native(pathService.resolve(cwd, rootCommonDir)), - NodeFS.realpathSync.native(pathService.resolve(worktreePath, linkedCommonDir)), - ); - yield* Ref.set(fetchAttempts, 0); - - yield* readRemoteStatus(cwd); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 1); - assert.lengthOf(warnings, 1); - assert.include(warnings[0], "Background Git fetch failed"); - - yield* TestClock.adjust("29 seconds"); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 1); - assert.lengthOf(warnings, 1); - - yield* TestClock.adjust("1 second"); - yield* readRemoteStatus(cwd); - assert.equal(yield* Ref.get(fetchAttempts), 2); - assert.lengthOf(warnings, 2); - - yield* TestClock.adjust("59 seconds"); - yield* readRemoteStatus(worktreePath); - assert.equal(yield* Ref.get(fetchAttempts), 2); - assert.lengthOf(warnings, 2); - - yield* TestClock.adjust("1 second"); - yield* readRemoteStatus(cwd); - assert.equal(yield* Ref.get(fetchAttempts), 3); - assert.lengthOf(warnings, 3); - }), - ).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - it.effect.each([ { name: "HTTPS credentials", @@ -1602,64 +794,17 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }); }), ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); - const cwd = yield* makeTmpDir(); - yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "main" }); - assert.equal(yield* Ref.get(attempts), 2); - }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), -); - -it.layer(layerTest)("GitVcsDriver core integration", (it) => { - describe("process environment", () => { - it.effect("preserves the caller locale for general Git subprocesses", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - - const locale = yield* git( - cwd, - ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], - { LC_ALL: "zh_CN.UTF-8" }, - ); - - assert.equal(locale, "zh_CN.UTF-8"); - }), - ); - }); - - describe("structured errors", () => { - it.effect("preserves structured spawn context and the platform cause", () => - Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const cwd = pathService.join(parent, "missing"); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const error = yield* driver - .execute({ - operation: "GitVcsDriver.test.missingCwd", - cwd, - args: ["status", "--short"], - }) - .pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.test.missingCwd", - command: "git", - argumentCount: 2, - cwd, - detail: "Failed to spawn Git process.", - }); - if (!(error.cause instanceof PlatformError.PlatformError)) { - return assert.fail("expected the original platform error cause"); - } - assert.equal(error.cause.reason._tag, "NotFound"); - assert.notInclude(error.detail, error.cause.message); - }), - ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "main" }); + assert.equal(yield* Ref.get(attempts), 2); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), +); +it.layer(layerTest)("GitVcsDriver core integration", (it) => { + describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1692,6 +837,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #8645 name the cause of a failed git command it.effect("names the branch conflict behind a failed worktree add", () => Effect.gen(function* () { const parent = yield* makeTmpDir(); @@ -1723,6 +869,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #8645 name the cause of a failed git command it.effect("names a missing repository behind a failed command", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1746,36 +893,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("appends the reason tag to a caller-supplied detail", () => - Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const cwd = pathService.join(parent, "repo"); - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* initRepoWithCommit(cwd); - yield* git(cwd, [ - "worktree", - "add", - pathService.join(parent, "taken"), - "-b", - "taken-branch", - ]); - - const error = yield* driver - .createWorktree({ - cwd, - refName: "taken-branch", - path: pathService.join(parent, "second"), - }) - .pipe(Effect.flip); - - assert.equal(error.detail, "git worktree add failed"); - assert.include(error.message, "git worktree add failed (branch_checked_out_in_worktree)"); - }), - ); - it.effect.each([ { action: "worktree add", detail: "git worktree add failed" }, { action: "switch", detail: "git checkout failed" }, @@ -1988,39 +1105,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("does not read hook output as a git failure reason", () => - Effect.gen(function* () { - const parent = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const remote = pathService.join(parent, "origin.git"); - const cwd = pathService.join(parent, "work"); - const fileSystem = yield* FileSystem.FileSystem; - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* fileSystem.makeDirectory(cwd); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["init", "--bare", remote]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* writeTextFile( - cwd, - ".git/hooks/pre-push", - '#!/bin/sh\necho "authentication failed" >&2\nexit 1\n', - ); - yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-push"), 0o755); - - const error = yield* driver - .execute({ - operation: "GitVcsDriver.test.hookOutput", - cwd, - args: ["push", "origin", "HEAD"], - env: { LC_ALL: "C" }, - }) - .pipe(Effect.flip); - - assert.notProperty(error, "reason"); - assert.notInclude(error.message, "(authentication_failed)"); - }), - ); - it.effect("leaves a tag collision unclassified rather than calling it a path", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2090,31 +1174,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.deepStrictEqual(refs.refs, []); }), ); - - it.effect("does not wrap a remove-worktree command failure in a synthetic error", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const notAWorktree = pathService.join(cwd, "not-a-worktree"); - yield* fileSystem.makeDirectory(notAWorktree); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - - const error = yield* driver.removeWorktree({ cwd, path: notAWorktree }).pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.removeWorktree", - command: "git", - argumentCount: 6, - cwd, - }); - assert.notProperty(error, "cause"); - assert.notProperty(error, "stderr"); - assert.notInclude(error.detail, "Git command failed in"); - }), - ); }); describe("review diff previews", () => { @@ -2240,60 +1299,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps the final path when NUL-separated git output is complete", () => - Effect.sync(() => { - const paths = splitNullSeparatedGitStdoutPaths({ - stdout: "complete.txt\0final.txt", - stdoutTruncated: false, - }); - - assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); - }), - ); - - it.effect("honors whitespace filtering for worktree and branch previews", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* git(cwd, ["checkout", "-b", "feature/whitespace"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - yield* git(cwd, ["add", "README.md"]); - yield* git(cwd, ["commit", "-m", "change whitespace"]); - yield* writeTextFile(cwd, "README.md", "# test\n"); - - const included = yield* driver.getReviewDiffPreview({ - cwd, - baseRef: initialBranch, - ignoreWhitespace: false, - }); - const ignored = yield* driver.getReviewDiffPreview({ - cwd, - baseRef: initialBranch, - ignoreWhitespace: true, - }); - - assert.isNotEmpty(included.sources.find((source) => source.kind === "working-tree")?.diff); - assert.isNotEmpty(included.sources.find((source) => source.kind === "branch-range")?.diff); - assert.strictEqual( - ignored.sources.find((source) => source.kind === "working-tree")?.diff, - "", - ); - assert.deepStrictEqual( - ignored.sources.find((source) => source.kind === "working-tree")?.files, - [], - ); - assert.deepStrictEqual( - ignored.sources.find((source) => source.kind === "branch-range")?.files, - [], - ); - assert.strictEqual( - ignored.sources.find((source) => source.kind === "branch-range")?.diff, - "", - ); - }), - ); - it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2458,21 +1463,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("keeps untracked files visible before the first commit", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - yield* writeTextFile(cwd, "untracked.txt", "visible before HEAD\n"); - - const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); - const source = preview.sources.find((candidate) => candidate.kind === "working-tree"); - - assert.include(source?.diff, "visible before HEAD"); - assert.equal(source?.truncated, false); - }), - ); - it.effect.each([false, true])( "keeps the preceding second cached in review previews (split: %s)", (splitIndex) => @@ -2732,32 +1722,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("attributes working-tree filesystem failures to the failing operation", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const error = yield* driver - .getReviewDiffFileContents( - makeReviewDiffFileContentsInput(cwd, { - changeType: "new", - oldPath: "missing.ts", - newPath: "missing.ts", - }), - ) - .pipe(Effect.flip); - - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath", - command: "fs.realPath", - cwd, - detail: "Could not resolve diff file 'missing.ts'.", - }); - }), - ); - it.effect("loads new and deleted files without reading their missing side", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3088,28 +2052,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("skips Changes totals instead of indexing thousands of untracked files", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* Effect.forEach( - Array.from({ length: 5_001 }, (_, index) => `bulk/${index}.txt`), - (file) => writeTextFile(cwd, file, "x\n"), - { concurrency: 32, discard: true }, - ); - yield* writeTextFile(cwd, "README.md", "changed\n"); - - const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); - assert.isTrue(status.hasWorkingTreeChanges); - assert.isUndefined(status.branchChanges); - - const preview = yield* driver.getReviewDiffPreview({ cwd }); - assert.isNotEmpty(preview.sources); - assert.isTrue(preview.sources.every((source) => source.truncated)); - }), - ); - it.effect("Changes does not show newer base commits as deletions", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3286,35 +2228,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports non-repository directories without failing", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const refs = yield* driver.listRefs({ cwd }); - assert.equal(refs.isRepo, false); - assert.deepStrictEqual(refs.refs, []); - }), - ); - - it.effect("reports refName and dirty state for a repository", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); - - const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - - assert.equal(status.isRepo, true); - assert.equal(status.branch, initialBranch); - assert.equal(status.hasWorkingTreeChanges, true); - assert.include( - status.workingTree.files.map((file) => file.path), - "feature.ts", - ); - }), - ); - it.effect("reports changes to a file named HEAD", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3387,215 +2300,66 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { { path: "README.md", insertions: 1, deletions: 0 }, ]); // The root is what the paths above are relative to, not the nested cwd, and it keeps - // the trailing space so the resolved editor target is the file that exists. - assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(cwd)); - assert.equal( - NodeFS.existsSync(path.join(status.workingTree.rootPath!, "nested", "new.txt")), - true, - ); - yield* driver.commit(nestedCwd, "Commit nested selection", "", { - stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, - }); - assert.equal(yield* git(cwd, ["status", "--porcelain"]), "M README.md"); - }), - ); - - it.effect("reports the linked worktree as the root of a nested directory in it", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const worktreePath = yield* makeTmpDir("git-vcs-driver-linked-"); - yield* initRepoWithCommit(cwd); - yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "add nested file"]); - yield* git(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); - yield* writeTextFile(worktreePath, "nested/tracked.txt", "one\ntwo\n"); - yield* writeTextFile(worktreePath, "nested/new.txt", "new\n"); - const nestedCwd = (yield* Path.Path).join(worktreePath, "nested"); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const status = yield* driver.statusDetails(nestedCwd); - - assert.deepEqual(status.workingTree.files, [ - { path: "nested/new.txt", insertions: 0, deletions: 0 }, - { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, - ]); - assert.equal( - NodeFS.realpathSync.native(status.workingTree.rootPath!), - NodeFS.realpathSync.native(worktreePath), - ); - }), - ); - - it.effect("reports default-branch delta separately from upstream delta", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "-u", "origin", initialBranch]); - yield* git(cwd, ["checkout", "-b", "feature/synced"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "feature.txt"]); - yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); - - const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - - assert.equal(status.hasUpstream, true); - assert.equal(status.aheadCount, 0); - assert.equal(status.behindCount, 0); - assert.equal(status.aheadOfDefaultCount, 1); - }), - ); - - it.effect("can read local files without traversing revision history", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "-u", "origin", initialBranch]); - yield* git(cwd, ["checkout", "-b", "feature/local-only"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "feature.txt"]); - yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* writeTextFile(cwd, "feature.txt", "feature\nlocal edit\n"); - yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); - - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commands: ReadonlyArray[] = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - commands.push(command.args); - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), + // the trailing space so the resolved editor target is the file that exists. + assert.equal(status.workingTree.rootPath, NodeFS.realpathSync.native(cwd)); + assert.equal( + NodeFS.existsSync(path.join(status.workingTree.rootPath!, "nested", "new.txt")), + true, ); + yield* driver.commit(nestedCwd, "Commit nested selection", "", { + stage: { filePaths: ["nested/new.txt", "nested/tracked.txt"] }, + }); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), "M README.md"); + }), + ); - for (const hasUpstream of [false, true]) { - if (hasUpstream) { - yield* git(cwd, ["push", "-u", "origin", "feature/local-only"]); - } - commands.length = 0; - const local = yield* driver.statusDetailsLocal(cwd, { includeDivergence: false }); - assert.isFalse(commands.some((args) => args.includes("rev-list"))); - assert.isTrue(commands.some((args) => args.includes("--no-ahead-behind"))); - assert.equal(local.hasUpstream, hasUpstream); - assert.isTrue(local.hasWorkingTreeChanges); - assert.deepEqual(local.workingTree.files, [ - { path: "feature.txt", insertions: 1, deletions: 0 }, - { path: "untracked.txt", insertions: 0, deletions: 0 }, - ]); + it.effect("reports the linked worktree as the root of a nested directory in it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreePath = yield* makeTmpDir("git-vcs-driver-linked-"); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "nested/tracked.txt", "one\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add nested file"]); + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* writeTextFile(worktreePath, "nested/tracked.txt", "one\ntwo\n"); + yield* writeTextFile(worktreePath, "nested/new.txt", "new\n"); + const nestedCwd = (yield* Path.Path).join(worktreePath, "nested"); + const driver = yield* GitVcsDriver.GitVcsDriver; - commands.length = 0; - const full = yield* driver.statusDetailsLocal(cwd); - assert.isTrue(commands.some((args) => args.includes("rev-list"))); - assert.equal(full.aheadCount, hasUpstream ? 0 : 1); - assert.equal(full.aheadOfDefaultCount, 1); - assert.deepEqual(local, { - ...full, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - }); - } + const status = yield* driver.statusDetails(nestedCwd); - yield* git(cwd, ["branch", "-f", initialBranch, "HEAD"]); - yield* git(cwd, ["update-ref", "refs/remotes/origin/trunk", "HEAD"]); - yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); - for (const base of [initialBranch, "missing-base"]) { - yield* git(cwd, ["config", "branch.feature/local-only.gh-merge-base", base]); - commands.length = 0; - const status = yield* driver.statusDetailsLocal(cwd, { - includeDivergence: false, - includeBranchChanges: true, - }); - assert.deepEqual(status.branchChanges, { - baseRef: `origin/${base === initialBranch ? initialBranch : "trunk"}`, - insertions: base === initialBranch ? 3 : 2, - deletions: 0, - }); - assert.lengthOf( - commands.filter((args) => args.join(" ") === "symbolic-ref refs/remotes/origin/HEAD"), - base === initialBranch ? 0 : 1, - ); - } + assert.deepEqual(status.workingTree.files, [ + { path: "nested/new.txt", insertions: 0, deletions: 0 }, + { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, + ]); + assert.equal( + NodeFS.realpathSync.native(status.workingTree.rootPath!), + NodeFS.realpathSync.native(worktreePath), + ); }), ); - it.effect("reports remote divergence without reading working-tree details", () => + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "-M", "trunk"]); + const { initialBranch } = yield* initRepoWithCommit(cwd); yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "-u", "origin", "trunk"]); - yield* git(cwd, ["checkout", "-b", "feature/remote-status"]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/synced"]); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "feature.txt"]); yield* git(cwd, ["commit", "-m", "feature commit"]); - yield* git(cwd, ["push", "-u", "origin", "feature/remote-status"]); - yield* writeTextFile(cwd, "untracked.txt", "local-only\n"); - yield* git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk"]); + yield* git(cwd, ["push", "-u", "origin", "feature/synced"]); - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commands: ReadonlyArray[] = []; - let originHeadReads = 0; - let failFirstOriginHead = false; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) { - return yield* Effect.die("expected a standard Git command"); - } - commands.push(command.args); - if (command.args.join(" ") === "symbolic-ref refs/remotes/origin/HEAD") { - originHeadReads++; - if (failFirstOriginHead && originHeadReads === 1) return makeNonRepositoryHandle(); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), - ); - const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - assert.equal(status.isRepo, true); - assert.equal(status.branch, "feature/remote-status"); - assert.equal(status.defaultBranch, "trunk"); - assert.equal(status.isDefaultBranch, false); - assert.equal(status.upstreamRef, "origin/feature/remote-status"); assert.equal(status.hasUpstream, true); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); assert.equal(status.aheadOfDefaultCount, 1); - assert.notProperty(status, "workingTree"); - assert.notProperty(status, "hasWorkingTreeChanges"); - assert.deepEqual( - commands.filter((args) => args.length === 1 && args[0] === "remote"), - [], - `cached remote status should not enumerate remotes: ${JSON.stringify(commands)}`, - ); - assert.equal(originHeadReads, 1); - - // With no main/master fallback, the retry must resolve trunk to preserve the ahead count. - originHeadReads = 0; - failFirstOriginHead = true; - const retried = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); - assert.deepEqual(retried, { ...status, defaultBranch: null }); - assert.equal(originHeadReads, 2); }), ); @@ -3928,32 +2692,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("creates, checks out, renames, and lists refs", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* driver.createRef({ cwd, refName: "feature/original" }); - const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); - assert.equal(switchRef.refName, "feature/original"); - - const renamed = yield* driver.renameBranch({ - cwd, - oldBranch: "feature/original", - newBranch: "feature/renamed", - }); - assert.equal(renamed.branch, "feature/renamed"); - assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); - - const refs = yield* driver.listRefs({ cwd }); - assert.equal( - refs.refs.find((refName) => refName.name === "feature/renamed")?.current, - true, - ); - }), - ); - it.effect("preserves exact custom names and refuses collisions without a suffix", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3980,23 +2718,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("returns the existing refName when rename source and target match", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const current = yield* git(cwd, ["branch", "--show-current"]); - const result = yield* driver.renameBranch({ - cwd, - oldBranch: current, - newBranch: current, - }); - - assert.equal(result.branch, current); - }), - ); - it.effect("rejects a missing branch without restoring a matching dirty file", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4088,76 +2809,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); }), ); - - it.effect("still creates and reuses remote tracking branches and allows detached refs", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-remote-"); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const commands: Array> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (ChildProcess.isStandardCommand(command)) commands.push(command.args); - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), - ); - const assertCheckout = ( - checkoutArgs: ReadonlyArray, - existenceReads: number, - trackingReads: number, - ) => { - assert.deepEqual( - commands.filter((args) => args[0] === "checkout"), - [checkoutArgs], - ); - assert.equal(commands.filter((args) => args[0] === "show-ref").length, existenceReads); - assert.equal(commands.filter((args) => args[0] === "for-each-ref").length, trackingReads); - assert.deepEqual( - commands.filter((args) => args[0] === "branch"), - [["branch", "--show-current"]], - ); - }; - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]); - - for (let attempt = 0; attempt < 2; attempt += 1) { - commands.length = 0; - const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); - assert.equal(result.refName, attempt === 0 ? "remote-only" : "review/renamed"); - assertCheckout( - attempt === 0 - ? ["checkout", "--track", "origin/remote-only", "--"] - : ["checkout", "review/renamed", "--"], - attempt === 0 ? 3 : 2, - 1, - ); - assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "origin/remote-only", - ); - if (attempt === 0) yield* git(cwd, ["branch", "-m", "review/renamed"]); - yield* driver.switchRef({ cwd, refName: initialBranch }); - } - yield* git(cwd, ["branch", "origin/remote-only"]); - commands.length = 0; - const shadowed = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); - assert.equal(shadowed.refName, "origin/remote-only"); - assertCheckout(["checkout", "origin/remote-only", "--"], 2, 0); - - const commit = yield* git(cwd, ["rev-parse", "HEAD"]); - commands.length = 0; - const detached = yield* driver.switchRef({ cwd, refName: commit }); - assert.equal(detached.refName, null); - assertCheckout(["checkout", commit, "--"], 2, 0); - assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit); - }), - ); }); describe("worktree operations", () => { @@ -4603,84 +3254,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports checkout progress during parallel worktree creation", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - for (let index = 0; index < 200; index += 1) { - yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); - } - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "add files"]); - const pathService = yield* Path.Path; - const worktreePath = pathService.join( - yield* makeTmpDir("git-worktrees-"), - "progress-worktree", - ); - const driver = yield* GitVcsDriver.GitVcsDriver; - const seen = yield* Ref.make>( - [], - ); - const claimed = yield* Ref.make<{ path: string; existed: boolean } | null>(null); - - yield* driver.createWorktree( - { cwd, path: worktreePath, refName: initialBranch, newRefName: "feature/progress" }, - { - progress: { - onWorktreeClaimed: (path) => - Ref.set(claimed, { path, existed: NodeFS.existsSync(path) }), - onCheckoutProgress: (update) => Ref.update(seen, (all) => [...all, update]), - }, - }, - ); - assert.deepEqual(yield* Ref.get(claimed), { path: worktreePath, existed: true }); - - const updates = yield* Ref.get(seen); - assert.isAbove(updates.length, 1); - assert.equal(updates.at(-1)?.percent, 100); - assert.equal(updates.at(-1)?.total, 201); - const completed = updates.map((update) => update.completed); - assert.deepEqual( - completed, - completed.toSorted((a, b) => a - b), - ); - }), - ); - - it.effect("creates and removes a worktree and its new local branch", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const pathService = yield* Path.Path; - const worktreePath = pathService.join( - yield* makeTmpDir("git-worktrees-"), - "feature-worktree", - ); - const driver = yield* GitVcsDriver.GitVcsDriver; - - const created = yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/worktree", - }); - - assert.equal(created.worktree.path, worktreePath); - assert.equal(created.worktree.refName, "feature/worktree"); - assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.deleteLocalBranch({ - cwd, - refName: "feature/worktree", - force: true, - }); - const fileSystem = yield* FileSystem.FileSystem; - assert.equal(yield* fileSystem.exists(worktreePath), false); - assert.notInclude(yield* driver.listLocalBranchNames(cwd), "feature/worktree"); - }), - ); - it.effect.each([false, true])( "cleans up links left by successful worktree removal, preserving ordinary files: %s", (keepFile) => @@ -4787,70 +3360,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("allows worktree removal to run longer than the default command timeout", () => - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const removalStarted = yield* Deferred.make(); - const delayedRemovalSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if ( - ChildProcess.isStandardCommand(command) && - command.args.join(" ").includes("worktree remove") - ) { - yield* Deferred.succeed(removalStarted, undefined); - yield* Effect.sleep("31 seconds"); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedRemovalSpawner), - Effect.provide(layerServerConfig), - ); - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "slow-removal"); - - yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/slow-removal", - }); - - const removal = yield* driver - .removeWorktree({ cwd, path: worktreePath, force: true }) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(removalStarted); - yield* TestClock.adjust("31 seconds"); - yield* Fiber.join(removal); - - assert.equal(yield* fileSystem.exists(worktreePath), false); - }), - ); - - it.effect("removes the same worktree path twice without failing", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const pathService = yield* Path.Path; - const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "shared"); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* driver.createWorktree({ - cwd, - path: worktreePath, - refName: initialBranch, - newRefName: "feature/shared", - }); - - yield* driver.removeWorktree({ cwd, path: worktreePath }); - yield* driver.removeWorktree({ cwd, path: worktreePath }); - }), - ); - it.effect("prunes stale registrations when removing an already-gone worktree", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4905,6 +3414,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + // regression: #12485 explain fetch failures without exposing remote output it.effect("explains a real fetch failure for a missing local remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -5157,65 +3667,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { - it.effect.each(["offline", "auth", "timeout"] as const)( - "does not retry a scoped fetch after %s", - (failure) => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const started = yield* Deferred.make(); - const attempts: Array> = []; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (!ChildProcess.isStandardCommand(command)) - return yield* Effect.die("unexpected command"); - if (command.args[0] !== "fetch") return yield* delegate.spawn(command); - attempts.push(command.args); - yield* Deferred.succeed(started, undefined); - return ChildProcessSpawner.makeHandle({ - ...makeNonRepositoryHandle(), - exitCode: - failure === "timeout" - ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(128)), - stderr: Stream.encodeText( - Stream.make( - failure === "auth" - ? "fatal: Authentication failed" - : "fatal: Could not resolve host", - ), - ), - }); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provide(layerServerConfig), - ); - const fetching = yield* driver - .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) - .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(started); - if (failure === "timeout") { - yield* TestClock.adjust("31 seconds"); - yield* TestClock.adjust("31 seconds"); - } - const result = yield* Fiber.join(fetching); - assert.isTrue(Result.isFailure(result)); - assert.equal(attempts.length, 1); - if (Result.isFailure(result)) { - assert.equal( - result.failure.detail, - failure === "timeout" - ? "Git command timed out." - : failure === "offline" - ? "Git could not reach the remote. Check the server's network connection and remote host, then retry." - : "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.", - ); - } - }), - ); - it.effect("creates a worktree from the latest fetched remote commit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -5543,43 +3994,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("allows pushes to run longer than the default command timeout", () => - Effect.gen(function* () { - const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; - const pushStarted = yield* Deferred.make(); - const delayedPushSpawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (ChildProcess.isStandardCommand(command) && command.args[0] === "push") { - yield* Deferred.succeed(pushStarted, undefined); - yield* Effect.sleep("31 seconds"); - } - return yield* delegate.spawn(command); - }), - ); - const driver = yield* makeGitVcsDriverCore().pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, delayedPushSpawner), - Effect.provide(layerServerConfig), - ); - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - - const pushing = yield* driver - .pushCurrentBranch(cwd, null) - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Deferred.await(pushStarted); - yield* TestClock.adjust("31 seconds"); - const pushed = yield* Fiber.join(pushing); - - assert.deepInclude(pushed, { - status: "pushed", - setUpstream: true, - }); - }), - ); - it.effect( "pushes upstream branches to the remote branch name, not the upstream shorthand", () => @@ -5816,21 +4230,6 @@ describe("Windows long path configuration", () => { }), ); - it.effect("leaves Git config untouched on other platforms", () => - Effect.gen(function* () { - assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false"); - assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name"); - }), - ); - - it("extends an inherited count spelled in a different case", () => { - assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "2" }), { - git_config_count: "3", - GIT_CONFIG_KEY_2: "core.longpaths", - GIT_CONFIG_VALUE_2: "true", - }); - }); - it("extends the per-command count when inherited and per-command casings differ", () => { assert.deepStrictEqual( windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: "0", git_config_count: "1" }), @@ -5877,6 +4276,7 @@ describe("Windows long path configuration", () => { } }); + // regression: #14917 Windows worktrees with long paths failed or stranded it("leaves a count Git would reject for Git to report", () => { for (const count of ["nope", "2x", "-1", "1.5", " ", "1 "]) { assert.deepStrictEqual( diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 1e1ed5254dec..e5c816977483 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -22,29 +22,6 @@ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; describe("VcsDriverRegistry", () => { - it.effect("routes directly by VCS driver kind for non-repository workflows", () => { - const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( - Layer.provide(NodeServices.layer), - Layer.provide( - Layer.mock(VcsProjectConfig.VcsProjectConfig)({ - resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), - }), - ), - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: () => Effect.succeed(processOutput("")), - }), - ), - ); - - return Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const driver = yield* registry.get("git"); - - assert.strictEqual(driver.capabilities.kind, "git"); - }).pipe(Effect.provide(layer)); - }); - const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), @@ -83,30 +60,6 @@ describe("VcsDriverRegistry", () => { ), ); - it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const repoDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); - yield* fs.makeDirectory(path.join(repoDir, ".git")); - const calls: string[] = []; - - yield* Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - const first = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); - const second = yield* registry.resolve({ cwd: repoDir, requestedKind: "git" }); - - assert.equal(first.repository.rootPath, repoDir); - assert.equal(second.repository.rootPath, repoDir); - assert.deepStrictEqual(calls, [ - "rev-parse --is-inside-work-tree", - "rev-parse --show-toplevel", - "rev-parse --git-common-dir", - ]); - }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("detects again when a cached repository is removed from disk", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -151,28 +104,6 @@ describe("VcsDriverRegistry", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("detects again when git init creates a repository between the cwd and its root", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-vcs-registry-" }); - const middleDir = path.join(parentDir, "a"); - const cwd = path.join(middleDir, "b"); - yield* fs.makeDirectory(path.join(parentDir, ".git")); - yield* fs.makeDirectory(cwd, { recursive: true }); - const calls: string[] = []; - - yield* Effect.gen(function* () { - const registry = yield* VcsDriverRegistry.VcsDriverRegistry; - assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, parentDir); - - yield* fs.makeDirectory(path.join(middleDir, ".git")); - - assert.equal((yield* registry.detect({ cwd }))?.repository.rootPath, middleDir); - }).pipe(Effect.provide(makeDiskBackedLayer(parentDir, calls))); - }).pipe(Effect.provide(NodeServices.layer)), - ); - it.effect("detects a repository created after a negative lookup", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 6617b15691d2..c781bd5c6da8 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -13,14 +12,9 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; -import { - VcsProcessExitError, - VcsProcessSpawnError, - VcsProcessTimeoutError, -} from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as ProcessRunner from "../processRunner.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -264,130 +258,6 @@ describe("VcsProcess.run", () => { }), ); - it.effect.each(["timeout", "spawn"] as const)( - "checkpoint commands do not retry %s failures", - (kind) => - Effect.gen(function* () { - let attempts = 0; - const fields = { command: "git", argumentCount: 2, cwd: "/workspace" }; - const failure = - kind === "timeout" - ? new ProcessRunner.ProcessTimeoutError({ ...fields, timeoutMs: 30_000 }) - : new ProcessRunner.ProcessSpawnError({ ...fields, cause: new Error("spawn failed") }); - const service = yield* VcsProcess.make.pipe( - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => - Effect.sync(() => { - attempts += 1; - }).pipe(Effect.andThen(Effect.fail(failure))), - }), - ); - const error = yield* service - .run({ - ...baseInput, - operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION, - }) - .pipe(Effect.flip); - expect(attempts).toBe(1); - expect(error._tag).toBe( - kind === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessSpawnError", - ); - }), - ); - - it.effect.each([ - { - name: "recovers after two lock failures", - failures: 2, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "bounds persistent lock failures", - failures: Infinity, - attempts: 3, - transient: true, - capture: true, - streaming: false, - }, - { - name: "does not retry unknown exits", - failures: Infinity, - attempts: 1, - transient: false, - capture: true, - streaming: false, - }, - { - name: "leaves other operations unchanged", - failures: Infinity, - attempts: 1, - transient: true, - capture: false, - streaming: false, - }, - { - name: "does not replay stdout callbacks", - failures: Infinity, - attempts: 1, - transient: true, - capture: true, - streaming: true, - }, - ])( - "checkpoint command retry $name", - ({ failures, attempts: expectedAttempts, transient, capture, streaming }) => - Effect.gen(function* () { - let attempts = 0; - const service = yield* VcsProcess.make.pipe( - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => - Effect.sync(() => { - attempts += 1; - return { - stdout: "", - stderr: - attempts > failures - ? "" - : transient - ? "fatal: Unable to create '/repo/index.lock': File exists" - : "fatal: index file corrupt", - code: ChildProcessSpawner.ExitCode(attempts > failures ? 0 : attempts), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }), - ); - const fiber = yield* service - .run({ - ...baseInput, - operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation, - ...(streaming ? { onStdoutChunk: () => {} } : {}), - }) - .pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("150 millis"); - const result = yield* Fiber.join(fiber); - expect(attempts).toBe(expectedAttempts); - if (failures === Infinity) { - if (Exit.isSuccess(result)) - return yield* Effect.die("Expected a capture command failure"); - const error = Cause.findErrorOption(result.cause); - if (error._tag === "None" || error.value._tag !== "VcsProcessExitError") { - return yield* Effect.die("Expected a Git exit error"); - } - expect(error.value.exitCode).toBe(expectedAttempts); - } else { - expect(Exit.isSuccess(result)).toBe(true); - } - }), - ); - it.effect("bounds a synthetic burst of GitHub API processes", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -445,22 +315,6 @@ describe("VcsProcess.run", () => { }), ); - it.effect("collects stdout", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.stdout", - command: "node", - args: ["-e", "process.stdout.write('hello')"], - cwd: process.cwd(), - }); - - expect(result.stdout).toBe("hello"); - expect(result.stderr).toBe(""); - expect(result.stdoutTruncated).toBe(false); - expect(result.stderrTruncated).toBe(false); - }).pipe(provideLive), - ); - it.effect("keeps git from opening a folder laid out as a bare repository", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -509,38 +363,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("fails with VcsProcessExitError for non-zero exits by default", () => - Effect.gen(function* () { - const secretArgument = "--token=super-secret-token"; - const secretStderr = "remote rejected super-secret-token"; - const error = yield* run({ - operation: "test.exit", - command: "node", - args: [ - "-e", - "process.stderr.write(process.argv[1]); process.exit(2)", - secretStderr, - secretArgument, - ], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(VcsProcessExitError); - expect(error).toMatchObject({ - operation: "test.exit", - command: "node", - argumentCount: 4, - exitCode: 2, - detail: "Process exited with a non-zero status.", - failureKind: "command-failed", - stderrLength: secretStderr.length, - stderrTruncated: false, - }); - expect(error.message).not.toContain(secretArgument); - expect(error.message).not.toContain(secretStderr); - }).pipe(provideLive), - ); - it.effect("classifies authentication failures without retaining stderr", () => Effect.gen(function* () { const secretStderr = "authentication failed for token super-secret-token"; @@ -591,24 +413,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("classifies HTTP 429 responses as rate limits", () => - Effect.gen(function* () { - const providerStderr = "HTTP 429: Too Many Requests. request-id=secret-value"; - const error = yield* run({ - operation: "test.rate-limit", - command: "node", - args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr], - cwd: process.cwd(), - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }); - expect(error.message).not.toContain(providerStderr); - }).pipe(provideLive), - ); - it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; @@ -630,68 +434,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("preserves real boundary causes without manufacturing structural ones", () => - Effect.gen(function* () { - const cause = new Error("secret stdin failure"); - const error = yield* captureProcessResult( - Effect.fail( - new ProcessRunner.ProcessStdinError({ - command: baseInput.command, - argumentCount: baseInput.args.length, - cwd: baseInput.cwd, - stdinBytes: 47, - cause, - }), - ), - ); - - expect(error).toMatchObject({ - _tag: "VcsProcessStdinWriteError", - operation: baseInput.operation, - stdinBytes: 47, - cause, - }); - expect(error.message).not.toContain(cause.message); - - const missingExitCodeError = yield* captureProcessResult( - Effect.succeed({ - stdout: "", - stderr: "", - code: null, - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }), - ); - - expect(missingExitCodeError).toMatchObject({ - _tag: "VcsProcessMissingExitCodeError", - operation: baseInput.operation, - command: baseInput.command, - cwd: baseInput.cwd, - argumentCount: baseInput.args.length, - }); - expect(missingExitCodeError).not.toHaveProperty("cause"); - }), - ); - - it.effect("returns output when non-zero exits are allowed", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.allowed-exit", - command: "node", - args: ["-e", "process.stderr.write('boom'); process.exit(2)"], - cwd: process.cwd(), - allowNonZeroExit: true, - }); - - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe("boom"); - }).pipe(provideLive), - ); - it.effect("truncates output and appends the marker when requested", () => Effect.gen(function* () { const result = yield* run({ @@ -709,21 +451,6 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); - it.effect("truncates without the marker when truncation markers are disabled", () => - Effect.gen(function* () { - const result = yield* run({ - operation: "test.truncate-silent", - command: "node", - args: ["-e", "process.stdout.write('x'.repeat(2048))"], - cwd: process.cwd(), - maxOutputBytes: 128, - }); - - expect(result.stdoutTruncated).toBe(true); - expect(result.stdout).not.toContain("[truncated]"); - }).pipe(provideLive), - ); - it.effect("fails with measured byte counts when output must not be truncated", () => Effect.gen(function* () { const error = yield* run({ @@ -759,21 +486,4 @@ describe("VcsProcess.run", () => { expect(Buffer.concat(chunks).toString()).toBe("x".repeat(131072) + "\0S final\0"); }).pipe(provideLive), ); - - it.effect("fails with VcsProcessTimeoutError on timeout", () => - Effect.gen(function* () { - const errorFiber = yield* run({ - operation: "test.timeout", - command: "node", - args: ["-e", "setTimeout(() => {}, 5000)"], - cwd: process.cwd(), - timeoutMs: 50, - }).pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(50)); - const error = yield* Fiber.join(errorFiber); - - expect(error).toBeInstanceOf(VcsProcessTimeoutError); - }).pipe(provideLive), - ); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 1c91cbadea8c..65e4068db361 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,20 +14,6 @@ const layerTest = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it.layer(layerTest)("uses an explicit requested VCS kind before config", (it) => { - it.effect("returns the requested kind", () => - Effect.gen(function* () { - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ - cwd: "/repo", - requestedKind: "jj", - }); - - assert.equal(kind, "jj"); - }), - ); - }); - it.layer(layerTest)("discovers .t3code/vcs.json from nested workspaces", (it) => { it.effect("returns the configured kind", () => Effect.gen(function* () { @@ -95,21 +81,6 @@ describe("VcsProjectConfig", () => { }); }); - it.layer(layerTest)("falls back to auto when no config exists", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - }), - ); - }); - it.layer(layerTest)("falls back to auto when config JSON is malformed", (it) => { it.effect("returns auto and logs the failed operation and path", () => { const messages: unknown[] = []; @@ -147,61 +118,4 @@ describe("VcsProjectConfig", () => { }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); }); - - it.layer(layerTest)("falls back to auto when the config path cannot be read", (it) => { - it.effect("retains the read failure context", () => { - const messages: unknown[] = []; - const logger = Logger.make(({ message }) => { - messages.push(message); - }); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const configPath = path.join(root, ".t3code", "vcs.json"); - yield* fileSystem.makeDirectory(configPath, { recursive: true }); - - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - const [error] = messages[0] as ReadonlyArray; - assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); - assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); - assert.deepInclude(error.cause, { _tag: "PlatformError" }); - assert.deepInclude(error, { - operation: "read", - cwd: root, - configPath, - _tag: "VcsProjectConfigError", - }); - }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); - }); - }); - - it.layer(layerTest)("falls back to auto when config kind is invalid", (it) => { - it.effect("returns auto", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-vcs-config-test-", - }); - const configDir = path.join(root, ".t3code"); - yield* fileSystem.makeDirectory(configDir, { recursive: true }); - yield* fileSystem.writeFileString( - path.join(configDir, "vcs.json"), - `{"vcs":{"kind":"svn"}}`, - ); - - const config = yield* VcsProjectConfig.VcsProjectConfig; - const kind = yield* config.resolveKind({ cwd: root }); - - assert.equal(kind, "auto"); - }), - ); - }); }); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts deleted file mode 100644 index ea03ed8a41ab..000000000000 --- a/apps/server/src/vcs/VcsProvisioningService.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ChildProcessSpawner } from "effect/process"; - -import * as VcsDriver from "./VcsDriver.ts"; -import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; -import * as VcsProvisioningService from "./VcsProvisioningService.ts"; - -const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); - -function makeDriver(calls: string[]): VcsDriver.VcsDriver["Service"] { - return { - capabilities: { - kind: "git", - supportsWorktrees: true, - supportsBookmarks: false, - supportsAtomicSnapshot: false, - supportsPushDefaultRemote: true, - ignoreClassifier: "native", - }, - execute: () => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - detectRepository: () => Effect.succeed(null), - isInsideWorkTree: () => Effect.succeed(false), - listWorkspaceFiles: () => - Effect.succeed({ - paths: [], - truncated: false, - freshness: { - source: "live-local", - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }), - listRemotes: () => - Effect.succeed({ - remotes: [], - freshness: { - source: "live-local", - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }), - filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), - initRepository: (input) => - Effect.sync(() => { - calls.push(`${input.kind ?? "default"}:${input.cwd}`); - }), - }; -} - -it.effect("routes repository initialization through an explicit VCS driver kind", () => { - const calls: string[] = []; - const driver = makeDriver(calls); - const layerTest = VcsProvisioningService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), - }), - ), - ); - - return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService.VcsProvisioningService; - yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); - - assert.deepStrictEqual(calls, ["git:/repo"]); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { - const calls: string[] = []; - const driver = makeDriver(calls); - const layerTest = VcsProvisioningService.layer.pipe( - Layer.provide( - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), - }), - ), - ); - - return Effect.gen(function* () { - const provisioning = yield* VcsProvisioningService.VcsProvisioningService; - yield* provisioning.initRepository({ cwd: "/repo" }); - - assert.deepStrictEqual(calls, ["default:/repo"]); - }).pipe(Effect.provide(layerTest)); -}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 22ad05d20acc..334c0d4747bc 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,7 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -10,7 +9,6 @@ import * as Fiber from "effect/Fiber"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; @@ -23,7 +21,7 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { GitCommandError, GitManagerError } from "@t3tools/contracts"; +import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -219,246 +217,6 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect.each(["status refresh", "initial stream"] as const)( - "%s updates local and PR status without fetching or pulling when the interval is zero", - (entryPoint) => { - let interval = Duration.zero; - let pullCalls = 0; - let invalidations = 0; - const refreshUpstreamValues: Array = []; - const local: VcsStatusLocalResult = { - ...baseLocalStatus, - isDefaultRef: true, - refName: "main", - }; - const remote = { ...remoteStatusWithPr, behindCount: 2 }; - const testLayer = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide( - Layer.succeed( - VcsStatusBroadcaster.VcsAutomaticFetchInterval, - Effect.sync(() => interval), - ), - ), - Layer.provide(layerBackgroundPolicy(() => true)), - Layer.provide( - Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { - isEnabled: () => Effect.succeed(true), - }), - ), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => Effect.succeed(local), - remoteStatus: (_input, options) => - Effect.sync(() => { - refreshUpstreamValues.push(options?.refreshUpstream); - return remote; - }), - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - invalidateStatus: () => - Effect.sync(() => { - invalidations += 1; - }), - pullCurrentBranch: () => - Effect.sync(() => { - pullCalls += 1; - return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; - }), - }), - ), - ); - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - if (entryPoint === "status refresh") { - const status = yield* broadcaster.refreshStatus("/repo"); - assert.deepStrictEqual(status, { ...local, ...remote }); - assert.equal(invalidations, 1); - } else { - const update = yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( - Stream.filter( - (event) => - event._tag === "remoteUpdated" || - (event._tag === "snapshot" && event.remote !== null), - ), - Stream.runHead, - ); - assert.deepStrictEqual(Option.getOrThrow(update), { - _tag: "remoteUpdated", - remote, - }); - assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo" }), { - ...local, - ...remote, - }); - } - assert.deepStrictEqual(refreshUpstreamValues, [false]); - assert.equal(pullCalls, 0); - - if (entryPoint === "status refresh") { - interval = Duration.minutes(1); - yield* broadcaster.refreshStatus("/repo"); - assert.deepStrictEqual(refreshUpstreamValues, [false, true, false]); - assert.equal(pullCalls, 1); - } - }).pipe(Effect.provide(testLayer)); - }, - ); - - it.effect.each([ - { refresh: "explicit", failure: "pull" }, - { refresh: "explicit", failure: "post-pull remote read" }, - { refresh: "explicit", failure: "recovery scan" }, - { refresh: "background", failure: "pull" }, - { refresh: "background", failure: "post-pull remote read" }, - { refresh: "background", failure: "recovery scan" }, - ] as const)( - "$refresh refresh reconciles local status after failure at $failure", - ({ refresh, failure }) => { - let local = { ...baseLocalStatus, isDefaultRef: true, refName: "main" }; - const afterPull = { ...local, hasWorkingTreeChanges: true }; - let pullCalls = 0; - let seeded = false; - const subscribed = Deferred.makeUnsafe(); - const outcome = Deferred.makeUnsafe<"updated" | "failed">(); - const recoveryError = new GitManagerError({ - operation: "test", - cwd: "/repo", - detail: "recovery scan failed", - }); - const logger = Logger.make(({ message }) => { - if (Array.isArray(message) && message[0] === "VCS remote status refresh failed") { - Deferred.doneUnsafe(outcome, Effect.succeed("failed")); - } - }); - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(layerBackgroundPolicy(() => true)), - Layer.provide( - Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { - isEnabled: () => Effect.succeed(true), - }), - ), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.suspend(() => - failure === "recovery scan" && pullCalls > 0 - ? Effect.fail(recoveryError) - : Effect.succeed(local), - ), - remoteStatus: () => - Effect.suspend(() => - failure === "post-pull remote read" && pullCalls > 0 - ? Effect.fail( - new GitManagerError({ - operation: "test", - cwd: "/repo", - detail: "post-pull read failed", - }), - ) - : Effect.succeed({ - ...baseRemoteStatus, - behindCount: 2, - pr: seeded ? remoteStatusWithPr.pr : null, - }), - ), - invalidateLocalStatus: () => Effect.void, - invalidateRemoteStatus: () => Effect.void, - invalidateStatus: () => Effect.void, - pullCurrentBranch: () => - Effect.suspend(() => { - pullCalls += 1; - local = afterPull; - return failure !== "post-pull remote read" - ? Effect.fail( - new GitCommandError({ - operation: "test", - command: "pull", - cwd: "/repo", - detail: "pull failed", - }), - ) - : Effect.succeed({ - status: "pulled" as const, - refName: "main", - upstreamRef: "origin/main", - }); - }), - }), - ), - ); - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Seed the same divergence so background recovery cannot rely on a moved remote ref. - yield* broadcaster.getStatus({ cwd: "/repo" }); - seeded = true; - if (refresh === "explicit") { - const result = yield* Effect.result(broadcaster.refreshStatus("/repo")); - if (failure === "recovery scan") { - assert.isTrue(result._tag === "Failure"); - if (result._tag === "Failure") assert.strictEqual(result.failure, recoveryError); - return; - } - assert.isTrue(result._tag === "Success"); - if (result._tag === "Success") assert.isTrue(result.success.hasWorkingTreeChanges); - } else { - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot" && !event.local.hasWorkingTreeChanges) { - return Deferred.succeed(subscribed, undefined).pipe(Effect.asVoid); - } - return Deferred.succeed(outcome, "updated").pipe(Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Deferred.await(subscribed); - yield* TestClock.adjust(Duration.seconds(30)); - assert.equal( - yield* Deferred.await(outcome), - failure === "recovery scan" ? "failed" : "updated", - ); - } - assert.equal(pullCalls, 1); - if (failure !== "recovery scan") { - assert.isTrue((yield* broadcaster.getStatus({ cwd: "/repo" })).hasWorkingTreeChanges); - } - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - layerTest, - TestClock.layer(), - Logger.layer([logger], { mergeWithExisting: true }), - ), - ), - ); - }, - ); - - it.effect("reuses the cached VCS status across repeated reads", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - - const first = yield* broadcaster.getStatus({ cwd: "/repo" }); - const second = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(first, baseStatus); - assert.deepStrictEqual(second, baseStatus); - assert.equal(state.localStatusCalls, 1); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 0); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -578,66 +336,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); - it.effect("turn-end refresh skips a loaded cwd when background policy pauses it", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - backgroundWorkEnabled: false, - }; - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - - it.effect("refreshes the cached snapshot after explicit invalidation", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - refName: "feature/updated-status", - }; - state.currentRemoteStatus = { - ...baseRemoteStatus, - aheadCount: 2, - }; - const refreshed = yield* broadcaster.refreshStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshed, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 1); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -710,42 +408,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTest)); }); - it.effect("refreshes only the cached local snapshot when requested", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - refName: "feature/local-only-refresh", - hasWorkingTreeChanges: true, - }; - - const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...baseRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTestFor(state))); - }); - it.effect.skipIf(!symlinksSupported)( "normalizes symlinked CWDs before cache lookup and workflow calls", () => { @@ -811,317 +473,6 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("passive streams retain cached remote status", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach( - broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), - (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }, - ).pipe(Effect.forkScoped); - - const snapshot = yield* Deferred.await(snapshotDeferred); - yield* TestClock.adjust("1 minute"); - assert.equal(state.remoteStatusCalls, 0); - yield* broadcaster.getStatus({ cwd: "/repo" }); - state.currentRemoteStatus = remoteStatusWithPr; - yield* broadcaster.refreshPullRequestStatus("/repo"); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(snapshot, { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - } satisfies VcsStatusStreamEvent); - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - const cachedSnapshot = yield* Stream.runHead( - broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), - ); - assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), { - _tag: "snapshot", - local: baseLocalStatus, - remote: remoteStatusWithPr, - }); - }).pipe(Effect.provide(layerTestFor(state))); - }); - - it.effect("loads remote status once when periodic refreshes are disabled", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: remoteStatusWithPr, - localStatusCalls: 0, - remoteStatusCalls: 0, - automaticFetchInterval: Effect.succeed(Duration.zero), - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - remoteStatusRefreshUpstreamValues: [] as Array, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }).pipe(Effect.forkIn(scope)); - - const snapshot = yield* Deferred.await(snapshotDeferred); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(snapshot, { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - } satisfies VcsStatusStreamEvent); - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); - - yield* TestClock.adjust(Duration.minutes(2)); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - - it.effect("retries the initial remote load when periodic refreshes are disabled", () => { - const state = { - currentLocalStatus: baseLocalStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - remoteStatusRefreshUpstreamValues: [] as Array, - }; - const privateCwd = "/private/user/workspace/repo"; - const nestedCause = new Error("private nested VCS failure"); - const messages: Array> = []; - const logger = Logger.make(({ message }) => { - messages.push(message as ReadonlyArray); - }); - let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide( - Layer.succeed( - VcsStatusBroadcaster.VcsAutomaticFetchInterval, - Effect.succeed(Duration.zero), - ), - ), - Layer.provide(layerBackgroundPolicy(() => true)), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: (_input, options) => - Effect.suspend(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues.push(options?.refreshUpstream); - if (state.remoteStatusCalls === 1) { - return Effect.fail( - new GitManagerError({ - operation: "VcsStatusBroadcaster.test", - cwd: privateCwd, - detail: "private initial remote status failure", - cause: nestedCause, - }), - ).pipe( - Effect.ensuring( - firstRemoteAttemptDeferred - ? Deferred.succeed(firstRemoteAttemptDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - ); - } - return Effect.succeed(remoteStatusWithPr); - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - }), - ), - ); - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const scope = yield* Scope.make(); - firstRemoteAttemptDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: privateCwd }), (event) => - event._tag === "remoteUpdated" - ? Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - - yield* Deferred.await(firstRemoteAttemptDeferred); - yield* Effect.yieldNow; - assert.equal(state.remoteStatusCalls, 1); - assert.deepStrictEqual( - messages.find((message) => message[0] === "VCS remote status refresh failed"), - [ - "VCS remote status refresh failed", - { - cwdLength: privateCwd.length, - reasonCount: 1, - failureCount: 1, - failureTags: ["GitManagerError"], - failureOperations: ["VcsStatusBroadcaster.test"], - defectCount: 0, - defectTags: [], - interruptionCount: 0, - consecutiveFailures: 1, - nextDelayMs: 30_000, - }, - ], - ); - - yield* TestClock.adjust(Duration.seconds(30)); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: remoteStatusWithPr, - } satisfies VcsStatusStreamEvent); - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 0); - assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); - - yield* Scope.close(scope, Exit.void); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerTest, - TestClock.layer(), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("delays automatic refresh when a cached remote snapshot is available", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - automaticFetchInterval: Effect.succeed(Duration.minutes(1)), - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => - event._tag === "snapshot" - ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - - yield* Deferred.await(snapshotDeferred); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - - yield* TestClock.adjust(Duration.seconds(59)); - assert.equal(state.remoteStatusCalls, 1); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Effect.yieldNow; - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 1); - - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - - it.effect.each([ - ["a push", { ...baseRemoteStatus, aheadCount: 1, aheadOfDefaultCount: 0 }], - ["a merged pull request", { ...baseRemoteStatus, aheadOfDefaultCount: 2 }], - ] as const)("re-reads local status when a fetch reflects %s", ([, initialRemote]) => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: initialRemote as VcsStatusRemoteResult | null, - localStatusCalls: 0, - remoteStatusCalls: 0, - automaticFetchInterval: Effect.succeed(Duration.minutes(1)), - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - yield* broadcaster.getStatus({ cwd: "/repo" }); - const scope = yield* Scope.make(); - const snapshotDeferred = yield* Deferred.make(); - const localUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => - event._tag === "snapshot" - ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) - : event._tag === "localUpdated" - ? Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(scope)); - yield* Deferred.await(snapshotDeferred); - - const pushedLocal: VcsStatusLocalResult = { - ...baseLocalStatus, - branchChanges: { baseRef: "origin/main", insertions: 0, deletions: 0 }, - }; - state.currentLocalStatus = pushedLocal; - state.currentRemoteStatus = { ...baseRemoteStatus, aheadOfDefaultCount: 0 }; - yield* TestClock.adjust(Duration.minutes(1)); - - assert.deepStrictEqual(yield* Deferred.await(localUpdatedDeferred), { - _tag: "localUpdated", - local: pushedLocal, - } satisfies VcsStatusStreamEvent); - yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(layerTestFor(state), TestClock.layer()))); - }); - it.effect("an explicit refresh reads local totals after the fetch", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, @@ -1154,102 +505,6 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state))); }); - it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), - 30_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(2, Duration.seconds(1))), - 60_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(3, Duration.seconds(1))), - 120_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.minutes(5))), - 300_000, - ); - assert.equal( - Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(20, Duration.seconds(1))), - 900_000, - ); - }); - - it("summarizes refresh causes without exposing nested failure details", () => { - const nestedCause = new Error("private nested failure detail"); - const failure = new GitManagerError({ - operation: "VcsStatusBroadcaster.remoteStatus", - cwd: "/private/user/workspace/repo", - detail: "private Git failure detail", - cause: nestedCause, - }); - const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); - - assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { - reasonCount: 2, - failureCount: 1, - failureTags: ["GitManagerError"], - failureOperations: ["VcsStatusBroadcaster.remoteStatus"], - defectCount: 1, - defectTags: ["TypeError"], - interruptionCount: 0, - }); - }); - - it.effect("does not start automatic remote refreshes without foreground client demand", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - const layerTest = VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide( - Layer.succeed( - VcsStatusBroadcaster.VcsAutomaticFetchInterval, - Effect.succeed(Duration.seconds(1)), - ), - ), - Layer.provide(layerBackgroundPolicy(() => false)), - Layer.provide( - Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - } satisfies Partial), - ), - ); - - return Effect.gen(function* () { - const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - const snapshot = yield* Stream.runHead(broadcaster.streamStatus({ cwd: "/repo" })); - - assert.isTrue(Option.isSome(snapshot)); - assert.equal(state.remoteStatusCalls, 0); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(layerTest)); - }); - it.effect("releases remote demand while passive observers remain", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index cfa253aafa31..41859b9dd907 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -86,7 +86,7 @@ function addUniqueDiagnosticValue(values: Array, value: string | undefin } } -export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { +function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { const failureTags: Array = []; const failureOperations: Array = []; const defectTags: Array = []; @@ -184,7 +184,7 @@ export const layerAutoPullPolicy = Layer.effect( }), ); -export function remoteRefreshFailureDelay( +function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, ) { diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 2a592139014c..150ac2c69131 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,7 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -253,39 +251,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect( - "browses a workspace with more than 25,000 entries without truncation", - () => - Effect.gen(function* () { - const cwd = yield* makeTempDir(); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - for (let directory = 0; directory < 26; directory++) { - const directoryPath = path.join(cwd, `folder-${directory}`); - yield* fileSystem.makeDirectory(directoryPath); - yield* Effect.forEach( - Array.from({ length: 1000 }, (_, i) => i), - (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""), - { concurrency: 32, discard: true }, - ); - } - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); - expect(root.entries).toHaveLength(26); - expect(root.truncated).toBe(false); - for (const directory of root.entries) { - const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path }); - expect(result.entries).toHaveLength(1000); - expect(result.truncated).toBe(false); - expect(result.entries).toContainEqual({ - path: `${directory.path}/file-999.txt`, - kind: "file", - }); - } - }), - 60_000, - ); - it.effect("includes images and other binary files in non-git workspace indexes", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); @@ -321,32 +286,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(images.truncated).toBe(false); }), ); - - it.effect("returns the complete cached workspace index", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir(); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "README.md"); - yield* writeTextFile(cwd, "node_modules/pkg/index.js"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.list({ cwd }); - - expect(result.entries).toEqual( - expect.arrayContaining([ - { path: "src", kind: "directory" }, - { path: "src/components", kind: "directory" }, - { - path: "src/components/Composer.tsx", - kind: "file", - }, - { path: "README.md", kind: "file" }, - ]), - ); - expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); - expect(result.truncated).toBe(false); - }), - ); }); describe("listIgnoredFiles", () => { @@ -370,102 +309,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); - - it.effect("returns nothing outside a git repository", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir(); - yield* writeTextFile(cwd, ".env", "A=1"); - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - expect(yield* workspaceEntries.listIgnoredFiles({ cwd })).toEqual({ - files: [], - truncated: false, - }); - }), - ); }); describe("search", () => { - it.effect("returns files and directories relative to cwd", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir(); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "README.md"); - yield* writeTextFile(cwd, ".git/HEAD"); - yield* writeTextFile(cwd, "node_modules/pkg/index.js"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - expect(paths).toContain("src"); - expect(paths).toContain("src/components"); - expect(paths).toContain("src/components/Composer.tsx"); - expect(paths).toContain("README.md"); - expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); - expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); - expect(result.truncated).toBe(false); - }), - ); - - it.effect("filters and ranks entries by query", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "src/components/composePrompt.ts"); - yield* writeTextFile(cwd, "docs/composition.md"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); - - expect(result.entries.length).toBeGreaterThan(0); - expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); - expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe( - true, - ); - }), - ); - - it.effect("supports fuzzy subsequence queries for composer path search", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "src/components/composePrompt.ts"); - yield* writeTextFile(cwd, "docs/composition.md"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); - const paths = result.entries.map((entry) => entry.path); - - expect(result.entries.length).toBeGreaterThan(0); - expect(paths).toContain("src/components"); - expect(paths).toContain("src/components/Composer.tsx"); - }), - ); - - it.effect("prioritizes exact basename matches ahead of broader path matches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); - - expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); - }), - ); - - it.effect("tracks truncation without sorting every fuzzy match", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - yield* writeTextFile(cwd, "src/components/composePrompt.ts"); - yield* writeTextFile(cwd, "docs/composition.md"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); - - expect(result.entries).toHaveLength(1); - expect(result.truncated).toBe(true); - }), - ); - it.effect("applies the file filter before limiting search results", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); @@ -487,44 +333,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("answers an empty file-filtered query with a bounded file listing", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "README.md"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "", - limit: 10, - kind: "file", - }); - - const paths = result.entries.map((entry) => entry.path); - expect(paths).toHaveLength(2); - expect(paths).toContain("src/index.ts"); - expect(paths).toContain("README.md"); - expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); - }), - ); - - it.effect("returns only directories for the directory filter", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); - yield* writeTextFile(cwd, "src/index.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 10, - kind: "directory", - }); - - expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); - expect(result.truncated).toBe(false); - }), - ); - it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -564,57 +372,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); }), ); - - it.effect("excludes .convex in non-git workspaces", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" }); - yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); - yield* writeTextFile(cwd, "src/keep.ts", "export {};"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); - const paths = result.entries.map((entry) => entry.path); - - expect(paths).toContain("src"); - expect(paths).toContain("src/keep.ts"); - expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); - }), - ); - - it.effect("supports typo-resistant file search through fff", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); - yield* writeTextFile(cwd, "src/components/Composer.tsx"); - - const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); - - expect(result.entries).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: "src/components/Composer.tsx" }), - ]), - ); - }), - ); - - it.effect("rebuilds the cached index after refresh fails", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); - yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const createSpy = vi.spyOn(FileFinder, "create"); - yield* workspaceEntries.list({ cwd }); - expect(createSpy).toHaveBeenCalledTimes(1); - - vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ - ok: false, - error: "scan failed", - }); - yield* workspaceEntries.refresh(cwd); - - yield* workspaceEntries.list({ cwd }); - expect(createSpy).toHaveBeenCalledTimes(2); - }), - ); }); describe("searchContents", () => { @@ -669,36 +426,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("filters whole-word matches by word boundaries without widening ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "note", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 0, end: 4 }], - }), - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 2, - matchRanges: [{ start: 9, end: 13 }], - }), - ]); - }), - ); - it.effect("finds later whole-word matches in a file after rejected raw matches", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); @@ -777,33 +504,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("matches punctuation-edged regex queries as whole words", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: true, - }); - - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 4 }, - { start: 5, end: 9 }, - ], - }); - }), - ); - it.effect("caps matches per file so one dense file cannot fill the page", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); @@ -848,26 +548,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("preserves invalid regex errors during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo)bar(", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.regexFallbackError).toBeDefined(); - expect(result.matches).toEqual([]); - }), - ); - it.effect("maps multi-byte lines to string-indexed ranges", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); @@ -892,29 +572,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("browse", () => { - it.effect("returns matching directories and excludes files", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const path = yield* Path.Path; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); - yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); - yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); - yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); - - const result = yield* workspaceEntries.browse({ - partialPath: path.join(cwd, "alp"), - }); - - expect(result).toEqual({ - parentPath: cwd, - entries: [ - { name: "alpha", fullPath: path.join(cwd, "alpha") }, - { name: "alpine", fullPath: path.join(cwd, "alpine") }, - ], - }); - }), - ); - it.effect("shows dot directories in directory mode and hidden-prefix mode", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -938,56 +595,5 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); - - it.effect("supports relative paths when cwd is provided", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const path = yield* Path.Path; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); - yield* writeTextFile(cwd, "packages/pkg.json", "{}"); - - const result = yield* workspaceEntries.browse({ - cwd, - partialPath: "./pack", - }); - - expect(result).toEqual({ - parentPath: cwd, - entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], - }); - }), - ); - - it.effect("rejects relative paths without cwd", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - - const error = yield* workspaceEntries - .browse({ - partialPath: "./src", - }) - .pipe(Effect.flip); - - expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); - expect(error.message).toBe( - "A current project is required to browse relative workspace path './src'.", - ); - }), - ); - - it.effect("returns an empty listing when the OS denies directory access", () => - Effect.gen(function* () { - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); - - const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - vi.mocked(NodeFSP.readdir).mockRejectedValueOnce(denied); - - const result = yield* workspaceEntries.browse({ - partialPath: yield* appendSeparator(cwd), - }); - expect(result).toEqual({ parentPath: cwd, entries: [] }); - }), - ); }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 8bdf91580e30..6dc5d4c08575 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -4,11 +4,9 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Tracer from "effect/Tracer"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -71,19 +69,6 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("getMetadata", () => { - it.effect("expands home-relative paths on the server", () => - Effect.gen(function* () { - const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const home = yield* makeTempDir; - yield* writeTextFile(home, "file", "hello"); - expect( - (yield* service - .getMetadata({ paths: ["~/file"] }) - .pipe(Effect.provideService(HostProcess.HomeDirectory, home))).entries, - ).toEqual([{ kind: "file", byteLength: 5 }]); - }), - ); - it.effect( "reports actual kinds for extensionless files, dotfiles, and dotted directories", () => @@ -138,48 +123,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i ]); }), ); - - it.effect.skipIf(!symlinksSupported)("follows file and directory symlinks", () => - Effect.gen(function* () { - const service = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "target", "text"); - yield* fs.makeDirectory(path.join(cwd, "directory")); - const fileLink = path.join(cwd, "file-link"); - const directoryLink = path.join(cwd, "directory-link"); - yield* fs.symlink(path.join(cwd, "target"), fileLink); - yield* fs.symlink(path.join(cwd, "directory"), directoryLink); - expect((yield* service.getMetadata({ paths: [fileLink, directoryLink] })).entries).toEqual([ - { kind: "file", byteLength: 4 }, - { kind: "directory" }, - ]); - }), - ); }); describe("readFile", () => { - it.effect("reads UTF-8 files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; - yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n"); - - const result = yield* workspaceFileSystem.readFile({ - cwd, - relativePath: "src/index.ts", - }); - - expect(result).toEqual({ - relativePath: "src/index.ts", - contents: "export const answer = 42;\n", - byteLength: 26, - truncated: false, - }); - }), - ); - it.effect("reads host files outside the workspace root by absolute path", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -253,21 +199,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects reads outside the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "../escape.md" }) - .pipe(Effect.flip); - - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); - }), - ); - it.effect.skipIf(!symlinksSupported)( "rejects symlinks that resolve outside the workspace root", () => @@ -313,31 +244,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects directories without manufacturing an I/O cause", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - yield* fileSystem.makeDirectory(path.join(cwd, "src")); - - const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); - for (const options of [{}, { allowMissing: true }]) { - const error = yield* workspaceFileSystem - .readFile(Object.assign({ cwd, relativePath: "src" }, options)) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); - expect(error).toMatchObject({ - workspaceRoot: cwd, - relativePath: "src", - resolvedPath, - }); - expect("cause" in error).toBe(false); - } - }), - ); - it.effect("rejects binary files without leaking their contents into the error", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -362,91 +268,9 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect("contents" in error).toBe(false); }), ); - - it.effect("preserves the real cause and path for I/O failures", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const path = yield* Path.Path; - const cwd = yield* makeTempDir; - const resolvedPath = path.join(cwd, "missing.txt"); - - const error = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "missing.txt" }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); - expect(error).toMatchObject({ - workspaceRoot: cwd, - relativePath: "missing.txt", - resolvedPath, - operationPath: resolvedPath, - operation: "realpath-target", - }); - expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); - }), - ); - - it.effect("returns an absent optional file without any failed spans", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const spans: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - - const result = yield* workspaceFileSystem - .readFile(Object.assign({ cwd, relativePath: "t3.json" }, { allowMissing: true })) - .pipe(Effect.withTracer(tracer), Effect.exit); - - expect(spans.length).toBeGreaterThan(0); - // Diagnostics counts nested failures even when the outer read recovers. - expect - .soft( - spans - .filter((span) => span.status._tag === "Ended" && Exit.isFailure(span.status.exit)) - .map((span) => span.name), - ) - .toEqual([]); - expect(result).toEqual( - Exit.succeed({ - relativePath: "t3.json", - contents: "", - byteLength: 0, - truncated: false, - exists: false, - }), - ); - }), - ); }); describe("writeFile", () => { - it.effect("writes files relative to the workspace root", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* workspaceFileSystem.writeFile({ - cwd, - relativePath: "plans/effect-rpc.md", - contents: "# Plan\n", - }); - const saved = yield* fileSystem - .readFileString(path.join(cwd, "plans/effect-rpc.md")) - .pipe(Effect.orDie); - - expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); - expect(saved).toBe("# Plan\n"); - }), - ); - it.effect("rejects writes outside a known workspace", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecaf6116c67a..574904b34356 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -20,47 +19,8 @@ const makeTempDir = Effect.fn("makeTempDir")(function* () { }); }); -const writeTextFile = Effect.fn("writeTextFile")(function* ( - cwd: string, - relativePath: string, - contents = "", -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const absolutePath = path.join(cwd, relativePath); - yield* fileSystem - .makeDirectory(path.dirname(absolutePath), { recursive: true }) - .pipe(Effect.orDie); - yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); -}); - it.layer(layerTest)("WorkspacePathsLive", (it) => { describe("normalizeWorkspaceRoot", () => { - it.effect("resolves an existing directory", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const cwd = yield* makeTempDir(); - - const resolved = yield* workspacePaths.normalizeWorkspaceRoot(cwd); - - expect(resolved).toBe(cwd); - }), - ); - - it.effect("rejects missing directories", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - - const error = yield* workspacePaths - .normalizeWorkspaceRoot(path.join(cwd, "missing")) - .pipe(Effect.flip); - - expect(error.message).toContain("Workspace root does not exist:"); - }), - ); - it.effect("creates missing directories when createIfMissing is enabled", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -78,114 +38,9 @@ it.layer(layerTest)("WorkspacePathsLive", (it) => { expect(stat.type).toBe("Directory"); }), ); - - it.effect("rejects file paths", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - const filePath = path.join(cwd, "README.md"); - yield* writeTextFile(cwd, "README.md", "# hi\n"); - - const error = yield* workspacePaths.normalizeWorkspaceRoot(filePath).pipe(Effect.flip); - - expect(error.message).toContain("Workspace root is not a directory:"); - }), - ); - - it.effect("preserves non-NotFound stat failures while validating the root", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const workspacePaths = yield* WorkspacePaths.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - stat: (path) => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "stat", - pathOrDescriptor: String(path), - description: "Test PermissionDenied stat failure.", - }), - ), - }), - ); - const path = yield* Path.Path; - const workspaceRoot = " ./permission-denied "; - const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); - - const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); - expect(error).toMatchObject({ - workspaceRoot, - normalizedWorkspaceRoot, - phase: "validate-existing", - }); - }), - ); - - it.effect("preserves stat failures while verifying a newly created root", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - let statCalls = 0; - const workspacePaths = yield* WorkspacePaths.make.pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fileSystem, - stat: (path) => { - statCalls += 1; - const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; - return Effect.fail( - PlatformError.systemError({ - _tag: reason, - module: "FileSystem", - method: "stat", - pathOrDescriptor: String(path), - description: `Test ${reason} stat failure.`, - }), - ); - }, - makeDirectory: () => Effect.void, - }), - ); - const path = yield* Path.Path; - const workspaceRoot = " ./created-then-unreadable "; - const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); - - const error = yield* workspacePaths - .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); - expect(error).toMatchObject({ - workspaceRoot, - normalizedWorkspaceRoot, - phase: "verify-created", - }); - }), - ); }); describe("resolveRelativePathWithinRoot", () => { - it.effect("resolves relative paths inside the workspace root", () => - Effect.gen(function* () { - const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const cwd = yield* makeTempDir(); - const path = yield* Path.Path; - - const resolved = yield* workspacePaths.resolveRelativePathWithinRoot({ - workspaceRoot: cwd, - relativePath: "plans/effect-rpc.md", - }); - - expect(resolved).toEqual({ - absolutePath: path.join(cwd, "plans/effect-rpc.md"), - relativePath: "plans/effect-rpc.md", - }); - }), - ); - it.effect("rejects paths that escape the workspace root", () => Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 2c61bbe76501..6b748e19396e 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -107,7 +107,7 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts deleted file mode 100644 index f9526112af3f..000000000000 --- a/apps/server/src/workspace/WorkspaceSearchIndex.native.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import * as NodeModule from "node:module"; -import type { Result } from "@ff-labs/fff-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; - -const requireForFff = NodeModule.createRequire(import.meta.url); -const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); - -function valueOf(result: Result): T { - if (!result.ok) throw new Error(result.error); - return result.value; -} - -it.effect("decodes native search and grep strings and keeps scores only through 200 results", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const basePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fff-native-" }); - const names = [ - ...Array.from({ length: 205 }, (_, index) => `file-${String(index).padStart(3, "0")}`), - "日本語", - "café", - "emoji-🦊", - ]; - const relativePaths = names.map((name) => `${name}/${name}.ts`); - const content = "prefix marker-日本語🦊\n"; - yield* Effect.forEach( - names, - (name) => - Effect.gen(function* () { - yield* fileSystem.makeDirectory(path.join(basePath, name)); - yield* fileSystem.writeFileString(path.join(basePath, name, `${name}.ts`), content); - }), - { concurrency: "unbounded" }, - ); - expect(requireForFff.resolve("@ff-labs/fff-node").replaceAll("\\", "/")).toMatch( - /\/dist\/index\.js$/u, - ); - const finder = yield* Effect.acquireRelease( - Effect.sync(() => - valueOf(FileFinder.create({ basePath, disableWatch: true, disableMmapCache: true })), - ), - (finder) => Effect.sync(() => finder.destroy()), - ); - expect(valueOf(yield* Effect.promise(() => finder.waitForIndexReady(10_000)))).toBe(true); - - const files200 = valueOf(finder.fileSearch("", { pageSize: 200 })); - const files201 = valueOf(finder.fileSearch("", { pageSize: 201 })); - expect(files200.items).toHaveLength(200); - expect(files200.scores).toHaveLength(200); - expect(files201.items).toHaveLength(201); - expect(files201.scores).toEqual([]); - expect(files201.items.slice(0, 200)).toEqual(files200.items); - const files = valueOf(finder.fileSearch("", { pageSize: 1000 })); - expect(files.items.map((item) => item.relativePath.normalize("NFC")).toSorted()).toEqual( - relativePaths.toSorted(), - ); - for (const item of files.items) { - expect(item.fileName.normalize("NFC")).toBe( - path.basename(item.relativePath).normalize("NFC"), - ); - expect(item.size).toBe(Buffer.byteLength(content)); - expect(Number.isFinite(item.modified)).toBe(true); - } - const exact = valueOf(finder.fileSearch("日本語.ts", { pageSize: 1 })); - expect(exact.items[0]?.fileName.normalize("NFC")).toBe("日本語.ts"); - expect(exact.scores[0]).toMatchObject({ exactMatch: true, matchType: "exact_filename" }); - - const dirs200 = valueOf(finder.directorySearch("", { pageSize: 200 })); - const dirs201 = valueOf(finder.directorySearch("", { pageSize: 201 })); - expect(dirs200.scores).toHaveLength(200); - expect(dirs201.items).toHaveLength(201); - expect(dirs201.scores).toEqual([]); - expect(dirs201.items.slice(0, 200)).toEqual(dirs200.items); - const directories = valueOf(finder.directorySearch("", { pageSize: 1000 })); - expect( - directories.items - .map((item) => item.relativePath.normalize("NFC")) - .filter(Boolean) - .toSorted(), - ).toEqual(names.map((name) => `${name}/`).toSorted()); - - const mixed200 = valueOf(finder.mixedSearch("", { pageSize: 200 })); - const mixed201 = valueOf(finder.mixedSearch("", { pageSize: 201 })); - expect(mixed200.scores).toHaveLength(200); - expect(mixed201.items).toHaveLength(201); - expect(mixed201.scores).toEqual([]); - expect(mixed201.items.slice(0, 200)).toEqual(mixed200.items); - const mixed = valueOf(finder.mixedSearch("", { pageSize: 1000 })); - expect(mixed.items.filter((item) => item.type === "file").map(({ item }) => item)).toEqual( - files.items, - ); - expect(mixed.items.filter((item) => item.type === "directory").map(({ item }) => item)).toEqual( - directories.items, - ); - - const grep = valueOf(finder.grep("marker", { mode: "plain", pageSize: 1000 })); - expect(grep.items.map((item) => item.relativePath.normalize("NFC")).toSorted()).toEqual( - relativePaths.toSorted(), - ); - for (const item of grep.items) { - expect(item.fileName.normalize("NFC")).toBe( - path.basename(item.relativePath).normalize("NFC"), - ); - expect(item.lineContent).toBe(content.trimEnd()); - expect(item.lineNumber).toBe(1); - expect(item.col).toBe(7); - expect(item.matchRanges).toEqual([[7, 13]]); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 4a3e80e92ecc..2c41e4ed0b3d 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -6,9 +6,7 @@ import { type GrepResult, } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -65,279 +63,6 @@ it.effect("filters image searches before applying the result limit", () => ), ); -it.effect("preserves unexpected FileFinder creation failures", () => - Effect.gen(function* () { - const cause = new Error("native initialization failed"); - vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { - throw cause; - }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexCreateFailed", - cwd: "/workspace/project", - reason: "FileFinder.create threw unexpectedly.", - cause, - }); - }), -); - -it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => - Effect.gen(function* () { - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ - ok: false, - error: "native index rejected the directory", - }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexCreateFailed", - cwd: "/workspace/project", - reason: "native index rejected the directory", - }); - expect(error.cause).toBeUndefined(); - }), -); - -it.effect("waits for the full content index warmup before returning", () => - Effect.gen(function* () { - const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); - const finder = { - destroy: vi.fn(), - waitForIndexReady, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); - - expect(waitForIndexReady).toHaveBeenCalledWith(15_000); - }), -); - -it.effect("preserves a full-index warmup timeout as a structured error", () => - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexScanTimedOut", - cwd: "/workspace/project", - timeout: "15 seconds", - }); - }), -); - -it.effect("returns partial path results when the initial scan times out", () => - Effect.scoped( - Effect.gen(function* () { - const mixedSearch = vi.fn(() => ({ - ok: true as const, - value: { - items: [ - { - type: "directory" as const, - item: { relativePath: "src", fileName: "src", fileCount: 1 }, - }, - { type: "file" as const, item: fileItem("src/index.ts") }, - ], - scores: [], - totalMatched: 2, - totalFiles: 1, - }, - })); - let scanning = true; - const finder = { - destroy: vi.fn(), - isScanning: vi.fn(() => scanning), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - mixedSearch, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths"); - const list = yield* searchIndex.list(); - const search = yield* searchIndex.search("src", 10); - scanning = true; - mixedSearch.mockImplementationOnce(() => { - scanning = false; - return { - ok: true as const, - value: { - items: [ - { - type: "directory" as const, - item: { relativePath: "src", fileName: "src", fileCount: 1 }, - }, - { type: "file" as const, item: fileItem("src/index.ts") }, - ], - scores: [], - totalMatched: 2, - totalFiles: 1, - }, - }; - }); - const searchCompletingDuringQuery = yield* searchIndex.search("src", 10); - scanning = false; - const completedSearch = yield* searchIndex.search("src", 10); - - expect(list).toEqual({ - entries: [ - { kind: "directory", path: "src" }, - { kind: "file", path: "src/index.ts" }, - ], - truncated: true, - }); - expect(search.truncated).toBe(true); - expect(searchCompletingDuringQuery.truncated).toBe(true); - expect(completedSearch.truncated).toBe(false); - expect(mixedSearch).toHaveBeenCalledTimes(4); - }), - ), -); - -it.effect("preserves FileFinder destroy failures as structured defects", () => - Effect.gen(function* () { - const cause = new Error("native destroy failed"); - const finder = { - destroy: vi.fn(() => { - throw cause; - }), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.hasDies(exit.cause)).toBe(true); - const error = Cause.squash(exit.cause); - expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexDestroyFailed", - cwd: "/workspace/project", - cause, - }); - } - }), -); - -it.effect("preserves search and refresh failures with operation context", () => - Effect.scoped( - Effect.gen(function* () { - const searchCause = new Error("native search failed"); - const refreshCause = new Error("native scan failed"); - const contentSearchCause = new Error("native grep failed"); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - mixedSearch: vi.fn(() => { - throw searchCause; - }), - grep: vi.fn(() => { - throw contentSearchCause; - }), - scanFiles: vi.fn(() => { - throw refreshCause; - }), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const query = "authorization: Bearer secret-token"; - const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const contentSearchError = yield* Effect.flip( - searchIndex.searchContents({ - query, - limit: 3, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }), - ); - const refreshError = yield* Effect.flip(searchIndex.refresh()); - - expect(searchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 4, - reason: "FileFinder.mixedSearch threw unexpectedly.", - cause: searchCause, - }); - expect(searchError).not.toHaveProperty("query"); - expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(contentSearchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 3, - reason: "FileFinder.grep threw unexpectedly.", - cause: contentSearchCause, - }); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); - expect(refreshError).toMatchObject({ - _tag: "WorkspaceSearchIndexRefreshFailed", - cwd: "/workspace/project", - reason: "FileFinder.scanFiles threw unexpectedly.", - cause: refreshCause, - }); - }), - ), -); - -it.effect("keeps returned search diagnostics out of the cause chain", () => - Effect.scoped( - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), - scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const query = "authorization: Bearer secret-token"; - const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const refreshError = yield* Effect.flip(searchIndex.refresh()); - - expect(searchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 4, - reason: "native query rejected", - }); - expect(searchError).not.toHaveProperty("query"); - expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(searchError.cause).toBeUndefined(); - expect(refreshError).toMatchObject({ - _tag: "WorkspaceSearchIndexRefreshFailed", - cwd: "/workspace/project", - reason: "native refresh rejected", - }); - expect(refreshError.cause).toBeUndefined(); - }), - ), -); - it.effect("continues whole-word searches after a filtered grep page", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/fileHeaderMimeType.test.ts b/apps/server/src/workspace/fileHeaderMimeType.test.ts deleted file mode 100644 index 9fe13cfd01d7..000000000000 --- a/apps/server/src/workspace/fileHeaderMimeType.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; - -describe("fileHeaderMimeType", () => { - it.each([ - [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], "image/png"], - [[0xff, 0xd8, 0xff], "image/jpeg"], - [[0x50, 0x4b, 0x03, 0x04], "application/zip"], - [[0x52, 0x49, 0x46, 0x46, 0xc2, 0xa0, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50], "image/webp"], - [[0x89, 0x50, 0x4e], undefined], - ])("classifies binary header %j", (header, expected) => { - expect(fileHeaderMimeType(new Uint8Array(header))).toBe(expected); - }); - - it.each([ - ["%PDF-1.7", "application/pdf"], - ["GIF89a", "image/gif"], - ["SQLite format 3\0", "application/vnd.sqlite3"], - ["RIFF1234WEBP", "image/webp"], - ["#!/usr/bin/env python3\n", "text/x-python"], - ["#!/usr/bin/node\n", "text/javascript"], - ["#!/bin/bash\n", "text/x-shellscript"], - ["#!/usr/bin/env ruby\n", "text/x-ruby"], - ["hello", undefined], - ["", undefined], - ["#!/usr/bin/env custom\n", undefined], - ])("classifies %j", (header, expected) => { - expect(fileHeaderMimeType(new TextEncoder().encode(header))).toBe(expected); - }); -}); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 941af3666fa3..85e83fd4b794 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -26,7 +26,6 @@ import * as CheckoutInspect from "./checkouts/CheckoutInspect.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { - ORCHESTRATION_PROTOCOL_VERSION, AuthSessionId, EnvironmentId, ProjectId, @@ -38,22 +37,14 @@ import { type ServerConfig, type ServerConfigStreamEvent, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; -import { ChildProcessSpawner } from "effect/process"; import * as SqlClient from "effect/sql/SqlClient"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -140,53 +131,13 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { - hasCompatibleOrchestrationProtocol, layerWsRpc, - resolveAvailableEditorsForConfig, - shouldUseBoundedThreadSnapshot, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, withLateEditorConfig, } from "./ws.ts"; -it("accepts only the current orchestration protocol before websocket RPC setup", () => { - assert.isTrue( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION}`), - ), - ); - assert.isFalse(hasCompatibleOrchestrationProtocol(new URL("https://host.test/ws"))); - assert.isFalse( - hasCompatibleOrchestrationProtocol( - new URL(`https://host.test/ws?orchestrationProtocol=${ORCHESTRATION_PROTOCOL_VERSION - 1}`), - ), - ); -}); - -it("keeps full thread snapshot fallback unless the client opts into bounded history", () => { - assert.isFalse(shouldUseBoundedThreadSnapshot({})); - assert.isFalse(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: false })); - assert.isTrue(shouldUseBoundedThreadSnapshot({ acceptBoundedSnapshot: true })); -}); - -it.effect("does not block server config when editor discovery never resolves", () => - Effect.gen(function* () { - const discoveryInterrupted = yield* Deferred.make(); - const responseFiber = yield* resolveAvailableEditorsForConfig( - Effect.never.pipe( - Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), - ), - ).pipe(Effect.forkChild); - - yield* TestClock.adjust(Duration.seconds(5)); - - const availableEditors = yield* Fiber.join(responseFiber); - yield* Deferred.await(discoveryInterrupted); - assert.deepEqual(availableEditors, []); - }), -); - const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; @@ -226,21 +177,6 @@ it.effect("resends late editors without rolling back updates already sent", () = }), ); -it.effect("sends no late snapshot when the scan matches the snapshot", () => - Effect.gen(function* () { - const events = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: ["vscode"] }), - Stream.empty, - { - resolveAvailableEditors: () => Effect.succeed(["vscode"]), - resolveFileManagerRevealKind: () => Effect.succeed(undefined), - }, - ).pipe(Stream.runCollect); - - assert.equal(events.length, 0); - }), -); - it.effect("resends a file manager reveal kind that missed the snapshot", () => Effect.gen(function* () { const events = yield* withLateEditorConfig( @@ -261,99 +197,6 @@ it.effect("resends a file manager reveal kind that missed the snapshot", () => }), ); -const makeParkedWindowsLauncher = Effect.gen(function* () { - const parkedProbes = yield* Queue.unbounded(); - const release = yield* Deferred.make(); - const launcher = yield* ExternalLauncher.make.pipe( - Effect.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Queue.offer(parkedProbes, undefined).pipe( - Effect.andThen(Deferred.await(release)), - Effect.as({ type: "File" } as FileSystem.File.Info), - ), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected spawn")), - ), - ), - ), - ); - const onWindows = (effect: Effect.Effect) => - effect.pipe( - Effect.provideService(HostProcess.Platform, "win32"), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { PATH: "C:\\t3-late-editors-test", PATHEXT: ".EXE" }, - }), - ), - ), - ); - return { - editors: { - resolveAvailableEditors: () => onWindows(launcher.resolveAvailableEditors()), - resolveFileManagerRevealKind: () => onWindows(launcher.resolveFileManagerRevealKind()), - }, - probeParked: Queue.take(parkedProbes), - releaseProbes: Deferred.succeed(release, undefined), - }; -}); - -it.effect("recovers editors after a real scan outlasts the config timeout", () => - Effect.gen(function* () { - const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; - - const snapshotFiber = yield* resolveAvailableEditorsForConfig( - editors.resolveAvailableEditors(), - ).pipe(Effect.forkChild); - yield* probeParked; - yield* TestClock.adjust(Duration.seconds(5)); - const snapshotEditors = yield* Fiber.join(snapshotFiber); - assert.deepEqual(snapshotEditors, []); - - const lateFiber = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: snapshotEditors }), - Stream.empty, - editors, - ).pipe(Stream.runCollect, Effect.forkChild); - yield* releaseProbes; - - const [late] = Array.from(yield* Fiber.join(lateFiber)); - assert.equal(late?.type, "snapshot"); - if (late?.type === "snapshot") { - assert.equal(late.config.availableEditors.includes("vscode"), true); - } - }).pipe(Effect.scoped), -); - -it.effect("recovers a reveal kind whose real probe outlasts the config timeout", () => - Effect.gen(function* () { - const { editors, probeParked, releaseProbes } = yield* makeParkedWindowsLauncher; - - const lateFiber = yield* withLateEditorConfig( - snapshotConfig({ availableEditors: ["file-manager"] }), - Stream.empty, - { - resolveAvailableEditors: () => Effect.succeed(["file-manager"]), - resolveFileManagerRevealKind: editors.resolveFileManagerRevealKind, - }, - ).pipe(Stream.runCollect, Effect.forkChild); - yield* probeParked; - yield* TestClock.adjust(Duration.seconds(6)); - yield* releaseProbes; - - const [late] = Array.from(yield* Fiber.join(lateFiber)); - assert.equal(late?.type, "snapshot"); - if (late?.type === "snapshot") { - assert.equal(late.config.shellRevealInFileManagerKind, "file-explorer"); - } - }).pipe(Effect.scoped), -); - const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5fbe1d2f3f95..3c4185274942 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -236,6 +236,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; @@ -296,7 +297,7 @@ const resolveDiscoveryForConfig = ( Effect.map(Option.getOrElse(onTimeout)), ); -export const resolveAvailableEditorsForConfig = ( +const resolveAvailableEditorsForConfig = ( discovery: Effect.Effect, E, R>, ) => resolveDiscoveryForConfig(discovery, () => []); @@ -665,14 +666,14 @@ const MAX_CLIENT_APP_VERSION_LENGTH = 64; const MAX_CLIENT_BROWSER_LENGTH = 64; const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; -export function hasCompatibleOrchestrationProtocol(url: URL): boolean { +function hasCompatibleOrchestrationProtocol(url: URL): boolean { return ( url.searchParams.get(ORCHESTRATION_PROTOCOL_QUERY_PARAM) === String(ORCHESTRATION_PROTOCOL_VERSION) ); } -export function shouldUseBoundedThreadSnapshot(input: { +function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; @@ -1266,6 +1267,7 @@ export const layerWsRpc = ( const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -1408,7 +1410,6 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { - ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( @@ -1770,6 +1771,7 @@ export const layerWsRpc = ( const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; + const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; const editorConfig = yield* resolveEditorConfig( yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), @@ -1811,6 +1813,7 @@ export const layerWsRpc = ( onSome: (root) => ({ scratchWorkspaceRoot: root }), }), newProjectsRoot: managedFolders.namedProjectsRoot, + ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), }; }); @@ -2970,10 +2973,9 @@ export const layerWsRpc = ( ...(project.value.faviconPath ? { projectFaviconPath: project.value.faviconPath } : {}), - projectCheckoutPending: - clone !== null && - clone.phase !== "done" && - clone.destinationPath === project.value.workspaceRoot, + ...(clone !== null && clone.destinationPath === project.value.workspaceRoot + ? { projectCheckout: clone.phase === "done" ? "cloned" : "pending" } + : {}), }); } const thread = yield* threadManagement diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5c6176b28c06..57549bec067f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -58,8 +58,12 @@ const packExecutableTargets = packExecutableTarget ] : undefined; +// The root config's `projects` route repo-root runs to package configs, +// including this one. Inheriting them would make this config a container. +const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; + export default mergeConfig( - baseConfig, + { ...baseConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { diff --git a/apps/web/package.json b/apps/web/package.json index 51f77e6cfd6b..b92cf05c8120 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { @@ -8,7 +8,6 @@ "dev": "vp dev", "build": "vp build", "preview": "vp preview", - "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests --project unit --project browser" }, "dependencies": { @@ -67,8 +66,8 @@ "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", - "mermaid": "^11.17.2", "morphicons": "^1.7.1", + "pele": "^1.8.0", "react": "catalog:", "react-dom": "catalog:", "react-markdown": "^10.1.0", @@ -94,7 +93,7 @@ "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", - "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-playwright": "5.0.5", "compression": "^1.8.2", "oxc-transform-react": "~0.152.0", "playwright": "1.60.0", diff --git a/apps/web/scripts/warm-dep-cache.ts b/apps/web/scripts/warm-dep-cache.ts index 004eb9b53eeb..1b3bbee03464 100644 --- a/apps/web/scripts/warm-dep-cache.ts +++ b/apps/web/scripts/warm-dep-cache.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists. +// @effect-diagnostics nodeBuiltinImport:off - plain Node script that dev-runner spawns on a cold cache. /** * The cache cannot be shared between worktrees: Vite's config hash includes * the absolute project root, so each worktree must warm its own. diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts deleted file mode 100644 index 3e6c1fad0448..000000000000 --- a/apps/web/src/appearanceContrast.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { applyAppearanceContrast } from "./appearanceContrast"; - -function makeRoot() { - const setProperty = vi.fn(); - return { - root: { style: { setProperty } } as unknown as HTMLElement, - setProperty, - }; -} - -describe("applyAppearanceContrast", () => { - it("boosts semantic contrast above the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 135); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); - }); - - it("supports the maximum contrast boost", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 200); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); - }); - - it("softens semantic contrast below the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 70); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); - }); - - it("disables contrast mixing at the default", () => { - const { root, setProperty } = makeRoot(); - - applyAppearanceContrast(root, 100); - - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); - }); -}); diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts deleted file mode 100644 index 31a2f1d779c5..000000000000 --- a/apps/web/src/appearanceFonts.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - areFontAdvancesMonospace, - clampCodeFontSize, - clampInterfaceFontSize, - clampPromptFontSize, - DEFAULT_CODE_FONT_STACK, - DEFAULT_SANS_FONT_STACK, - appearanceFontStack, - cssFontFamilies, - resolveDefaultFamilyLabel, - resolveTerminalFontPreference, - resolveTerminalFontSizePreference, -} from "./appearanceFonts"; - -describe("areFontAdvancesMonospace", () => { - it("accepts a fixed advance and rejects any proportional glyph", () => { - expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); - expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); - expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); - }); - - it("fails open when canvas metrics are unavailable", () => { - expect(areFontAdvancesMonospace([])).toBe(true); - expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); - }); -}); - -describe("cssFontFamilies", () => { - it("returns null for effectively empty input", () => { - expect(cssFontFamilies("")).toBeNull(); - expect(cssFontFamilies(" ")).toBeNull(); - expect(cssFontFamilies(" , , ")).toBeNull(); - }); - - it("quotes names with spaces and keeps single idents bare", () => { - expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); - expect(cssFontFamilies("monospace")).toBe("monospace"); - expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); - }); - - it("normalizes comma-separated lists and strips embedded quotes", () => { - expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); - expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); - }); - - it("quotes names that are not single CSS idents", () => { - expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); - expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); - }); -}); - -describe("resolveDefaultFamilyLabel", () => { - it("skips generic keywords and returns null for a stack of only generics", () => { - expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); - expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); - }); -}); - -describe("appearanceFontStack", () => { - it("prepends the custom family to the default stack", () => { - expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( - `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, - ); - }); - - it("falls back to the default stack when unset", () => { - expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); - }); -}); - -describe("resolveTerminalFontPreference", () => { - it("inherits the code font in simple mode", () => { - expect( - resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), - ).toBe("Fira Code"); - expect( - resolveTerminalFontPreference({ - advanced: false, - code: "Fira Code", - terminal: "Berkeley Mono", - }), - ).toBe("Fira Code"); - }); - - it("keeps code and terminal fonts independent in advanced mode", () => { - expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( - "", - ); - expect( - resolveTerminalFontPreference({ - advanced: true, - code: "Fira Code", - terminal: "Berkeley Mono", - }), - ).toBe("Berkeley Mono"); - }); -}); - -describe("resolveTerminalFontSizePreference", () => { - it("inherits the code font size in simple mode", () => { - expect(resolveTerminalFontSizePreference({ advanced: false, code: 15, terminal: 12 })).toBe(15); - }); - - it("keeps code and terminal font sizes independent in advanced mode", () => { - expect(resolveTerminalFontSizePreference({ advanced: true, code: 15, terminal: 12 })).toBe(12); - }); -}); - -describe("font size clamping", () => { - it("keeps sizes inside the ranges the UI can absorb", () => { - expect(clampInterfaceFontSize(16)).toBe(16); - expect(clampInterfaceFontSize(2)).toBe(12); - expect(clampInterfaceFontSize(96)).toBe(20); - expect(clampPromptFontSize(40)).toBe(20); - expect(clampCodeFontSize(1)).toBe(10); - }); - - it("rounds fractional values and falls back for unusable input", () => { - expect(clampCodeFontSize(13.4)).toBe(13); - expect(clampInterfaceFontSize(Number.NaN)).toBe(16); - expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); - }); -}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3341ec27c2c1..c816836ae8ae 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -46,7 +46,7 @@ function quoteFontFamilyName(name: string): string { return `"${bare.replaceAll('"', "")}"`; } -export function cssFontFamilies(input: string): string | null { +function cssFontFamilies(input: string): string | null { const families = input .split(",") .map(quoteFontFamilyName) @@ -110,7 +110,7 @@ function clampFontSize(value: number, minimum: number, maximum: number, fallback return Math.min(maximum, Math.max(minimum, Math.round(value))); } -export function clampInterfaceFontSize(value: number): number { +function clampInterfaceFontSize(value: number): number { return clampFontSize( value, MIN_INTERFACE_FONT_SIZE, @@ -119,11 +119,11 @@ export function clampInterfaceFontSize(value: number): number { ); } -export function clampPromptFontSize(value: number): number { +function clampPromptFontSize(value: number): number { return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); } -export function clampCodeFontSize(value: number): number { +function clampCodeFontSize(value: number): number { return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); } @@ -165,7 +165,7 @@ const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "ita const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; const MONOSPACE_ADVANCE_TOLERANCE = 0.01; -export function areFontAdvancesMonospace(advances: readonly number[]): boolean { +function areFontAdvancesMonospace(advances: readonly number[]): boolean { const reference = advances[0]; if ( reference === undefined || diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index 13ff3ea8e579..000000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - AuthFilesystemReadScope, - EnvironmentAuthorizationError, - EnvironmentId, - ThreadId, - type AuthSessionState, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; -import { beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - session: null as Pick | null, - phase: "connected" as "connected" | "offline", - assetAtom: {}, - mint: vi.fn(), - assetQuery: vi.fn(), -})); - -vi.mock("react", () => ({ useCallback: (callback: A) => callback })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => - atom === state.assetAtom - ? AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }) - : AsyncResult.initial(false), -})); -vi.mock("~/state/session", () => ({ - usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://host.test" } }), -})); -vi.mock("~/state/filesystem", async () => { - const { resolveFilesystemReadAccess } = await import("@t3tools/client-runtime/state/filesystem"); - return { - useFilesystemReadAccess: () => - resolveFilesystemReadAccess({ - isCatalogReady: true, - connection: { phase: state.phase, error: null }, - session: state.session, - sessionError: null, - }), - }; -}); -vi.mock("~/state/assets", () => ({ - assetEnvironment: { createUrl: state.assetQuery }, -})); -vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint })); - -import { useAssetUrlRefresh, useAssetUrlState } from "./assetUrls"; - -const environmentId = EnvironmentId.make("asset-environment"); -const threadId = ThreadId.make("asset-thread"); -const resource = { _tag: "media-file", threadId, path: "/repo/image.png" } as const; - -beforeEach(() => { - state.session = null; - state.phase = "connected"; - state.assetQuery.mockReset().mockReturnValue(state.assetAtom); - state.mint - .mockReset() - .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 })); -}); - -it.each(["workspace-file", "media-file"] as const)( - "keeps %s loading until its file grant resolves", - (_tag) => { - expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ _tag: "Loading" }); - expect(state.assetQuery).not.toHaveBeenCalled(); - - state.session = { authenticated: true, scopes: [AuthFilesystemReadScope] }; - expect(useAssetUrlState(environmentId, { ...resource, _tag })).toEqual({ - _tag: "Success", - expiresAt: 1, - url: "https://host.test/api/assets/image.png", - }); - }, -); - -it("hides host assets with a denied grant while preserving attachments", () => { - state.session = { authenticated: true, scopes: [] }; - expect(useAssetUrlState(environmentId, resource)).toEqual({ - _tag: "Failure", - reason: "missing-file-access", - }); - expect(state.assetQuery).not.toHaveBeenCalled(); - expect(useAssetUrlState(environmentId, { _tag: "attachment", attachmentId: "upload" })).toEqual({ - _tag: "Success", - expiresAt: 1, - url: "https://host.test/api/assets/image.png", - }); -}); - -it("stops waiting for an unresolved grant when the connection is offline", () => { - state.phase = "offline"; - expect(useAssetUrlState(environmentId, resource)).toEqual({ _tag: "Failure" }); - expect(state.assetQuery).not.toHaveBeenCalled(); -}); - -it("lets the server authorize an explicit refresh before the client grant loads", async () => { - await expect(useAssetUrlRefresh(environmentId, resource)()).resolves.toBe( - "https://host.test/api/assets/image.png", - ); - expect(state.mint).toHaveBeenCalledWith({ environmentId, input: { resource } }); - - const denied = new EnvironmentAuthorizationError({ - message: "This connection cannot read host files.", - requiredScope: AuthFilesystemReadScope, - }); - state.mint.mockResolvedValue(AsyncResult.failure(Cause.fail(denied))); - await expect(useAssetUrlRefresh(environmentId, resource)()).rejects.toBe(denied); -}); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 6add5c1856e7..86823d66b567 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -8,11 +8,10 @@ import { import { createBrowserHistory } from "@tanstack/react-router"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; -import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./lib/runtime"; +import { __setPrimaryHttpRunnerForTests } from "./lib/runtime"; type TestWindow = { location: URL; @@ -77,21 +76,6 @@ function installTestBrowser(url: string) { return testWindow; } -function installDesktopBootstrap() { - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }, - ], - } as unknown as DesktopBridge; -} - function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -181,108 +165,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); }); - it("uses https urls when the primary environment uses wss", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); - vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "https://remote.example.com/api/auth/session", - ); - }); - - it("uses the current origin as an auth proxy base for local dev environments", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - installTestBrowser("http://localhost:5735/"); - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "http://localhost:5735/api/auth/session", - ); - }); - - it("uses the vite proxy for desktop-managed loopback auth requests during local dev", async () => { - await installAuthApi({ session: () => unauthenticatedSession(DESKTOP_AUTH) }); - vi.stubEnv("VITE_DEV_SERVER_URL", "http://127.0.0.1:5733"); - - const testWindow = installTestBrowser("http://127.0.0.1:5733/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Windows", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - }, - ], - } as unknown as DesktopBridge; - - const { resolveInitialServerAuthGateState, resolvePrimaryEnvironmentHttpUrl } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: DESKTOP_AUTH, - }); - expect(resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toBe( - "http://127.0.0.1:5733/api/auth/session", - ); - }); - - it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { - await installAuthApi({ session: () => unauthenticatedSession(LOOPBACK_AUTH) }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - }); - - it("retries transient auth session bootstrap failures after restart", async () => { - vi.useFakeTimers(); - let attempts = 0; - const request = HttpClientRequest.get("http://localhost/api/auth/session"); - const response = HttpClientResponse.fromWeb( - request, - new Response("Bad Gateway", { status: 502 }), - ); - const runner: PrimaryHttpEffectRunner = async () => { - attempts += 1; - if (attempts < 4) { - throw new HttpClientError.HttpClientError({ - reason: new HttpClientError.StatusCodeError({ request, response }), - }); - } - return unauthenticatedSession(LOOPBACK_AUTH) as A; - }; - __setPrimaryHttpRunnerForTests(runner); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(gateStatePromise).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - expect(attempts).toBe(4); - }); - it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); @@ -292,14 +174,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testWindow.location.searchParams.get("token")).toBeNull(); }); - it("accepts query-string pairing tokens as a backward-compatible fallback", async () => { - const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); - const { takePairingTokenFromUrl } = await import("./environments/primary"); - - expect(takePairingTokenFromUrl()).toBe("pairing-token"); - expect(testWindow.location.searchParams.get("token")).toBeNull(); - }); - it.each([ { suffix: "#token=replacement-token", cached: false }, { suffix: "#token=replacement-token", cached: true }, @@ -433,261 +307,6 @@ describe("resolveInitialServerAuthGateState", () => { }, ); - it("allows manual token submission after the initial auth check requires pairing", async () => { - const nextSession = sequence( - unauthenticatedSession(LOOPBACK_AUTH), - authenticatedSession(LOOPBACK_AUTH), - ); - const testApi = await installAuthApi({ - session: nextSession, - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { resolveInitialServerAuthGateState, submitServerAuthCredential } = - await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "requires-auth", - auth: LOOPBACK_AUTH, - }); - await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBe(2); - }); - - it("keeps manual token submission pending until the session is authenticated", async () => { - vi.useFakeTimers(); - let authenticated = false; - let settled = false; - try { - const testApi = await installAuthApi({ - session: () => - authenticated - ? authenticatedSession(LOOPBACK_AUTH) - : unauthenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { submitServerAuthCredential } = await import("./environments/primary"); - - const submission = submitServerAuthCredential("retry-token").finally(() => { - settled = true; - }); - await vi.advanceTimersByTimeAsync(0); - - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBe(1); - expect(settled).toBe(false); - - authenticated = true; - await vi.advanceTimersByTimeAsync(100); - await expect(submission).resolves.toBeUndefined(); - expect(testApi.calls.session).toBe(2); - } finally { - vi.useRealTimers(); - } - }); - - it("fails manual token submission when the session is not established", async () => { - vi.useFakeTimers(); - try { - const testApi = await installAuthApi({ - session: () => unauthenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = - await import("./environments/primary/auth"); - - const submission = submitServerAuthCredential("retry-token"); - const failure = submission.then( - () => null, - (error: unknown) => error, - ); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); - expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); - expect(testApi.calls.session).toBeGreaterThan(1); - } finally { - vi.useRealTimers(); - } - }); - - it("rejects a blank pairing token with a structured validation error", async () => { - const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = - await import("./environments/primary/auth"); - - const error = await submitServerAuthCredential(" ").then( - () => null, - (failure: unknown) => failure, - ); - - expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); - expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentPairingCredentialRequiredError", - providedLength: 3, - message: "Enter a pairing token to continue.", - }); - }); - - it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { - const cause = new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }); - const testApi = await installAuthApi({ - browserSession: () => Effect.fail(cause), - }); - - const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = - await import("./environments/primary"); - - const error = await submitServerAuthCredential("bad-token").then( - () => null, - (failure: unknown) => failure, - ); - expect(error).toMatchObject({ - _tag: "PrimaryEnvironmentPairingCredentialRejectedError", - providedLength: 9, - message: "Invalid pairing token. Check the token and try again.", - }); - expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); - if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { - throw new Error("Expected a structured rejected pairing credential error."); - } - expect(error.cause).toMatchObject({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); - }); - - it("derives primary request messages from structural request context", async () => { - const cause = new Error("private transport detail"); - const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); - const error = PrimaryEnvironmentRequestError.fromCause({ - operation: "list-pairing-links", - cause, - }); - - expect(error.status).toBe(500); - expect(error.cause).toBe(cause); - expect(error.message).toBe( - "Primary environment request failed during list-pairing-links (HTTP 500).", - ); - expect(error.message).not.toContain(cause.message); - }); - - it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { - vi.useFakeTimers(); - const nextSession = sequence( - unauthenticatedSession(DESKTOP_AUTH), - unauthenticatedSession(DESKTOP_AUTH), - authenticatedSession(DESKTOP_AUTH), - ); - const testApi = await installAuthApi({ - session: nextSession, - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstraps: () => [ - { - id: "primary", - label: "Windows", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }, - ], - } as unknown as DesktopBridge; - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(100); - - await expect(gateStatePromise).resolves.toEqual({ status: "authenticated" }); - expect(testApi.calls.session).toBe(3); - }); - - it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { - vi.useFakeTimers(); - const testApi = await installAuthApi({ - session: () => unauthenticatedSession(DESKTOP_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - - installDesktopBootstrap(); - - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - const gateStatePromise = resolveInitialServerAuthGateState(); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(gateStatePromise).resolves.toEqual({ - status: "requires-auth", - auth: DESKTOP_AUTH, - errorMessage: "Timed out waiting for authenticated session after bootstrap.", - }); - expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); - }); - - it("memoizes the authenticated gate state after the first successful read", async () => { - const testApi = await installAuthApi({ - session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), - }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - expect(testApi.calls.session).toBe(1); - }); - - it("exchanges a URL token when the browser already has a session", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - const testWindow = installTestBrowser("http://localhost/#token=reusable-token"); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - - expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); - expect(testWindow.location.hash).toBe(""); - }); - - it("exchanges a root URL token after caching an authenticated state", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), - }); - const testWindow = installTestBrowser("http://localhost/"); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - testWindow.location = new URL("http://localhost/#token=reusable-token"); - - await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); - - expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); - expect(testApi.calls.session).toBe(3); - }); - it("makes later callers wait for a URL token that arrives during bootstrap", async () => { let releaseExchange!: () => void; const exchangeRelease = new Promise((resolve) => { @@ -749,20 +368,6 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); - it("does not exchange a token during an ordinary authenticated load", async () => { - const testApi = await installAuthApi({ - session: () => authenticatedSession(LOOPBACK_AUTH), - browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), - }); - const { resolveInitialServerAuthGateState } = await import("./environments/primary"); - - await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ - status: "authenticated", - }); - - expect(testApi.calls.browserSession).toEqual([]); - }); - it("reports a rejected URL token without caching false success", async () => { const cause = new EnvironmentAuthInvalidError({ code: "auth_invalid", @@ -791,31 +396,4 @@ describe("resolveInitialServerAuthGateState", () => { }); expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]); }); - - it("creates a pairing credential from the authenticated auth endpoint", async () => { - const testApi = await installAuthApi({ - pairingCredential: (payload) => - Effect.succeed({ - id: "pairing-link-1", - credential: "pairing-token", - ...(payload.label === undefined ? {} : { label: payload.label }), - expiresAt: SESSION_EXPIRES_AT, - }), - }); - const { createServerPairingCredential } = await import("./environments/primary"); - - const credential = await createServerPairingCredential({ - label: "Julius iPhone", - scopes: ["orchestration:read"], - }); - expect(credential).toMatchObject({ - id: "pairing-link-1", - credential: "pairing-token", - label: "Julius iPhone", - }); - expect(DateTime.formatIso(credential.expiresAt)).toBe("2026-04-05T00:00:00.000Z"); - expect(testApi.calls.pairingCredential).toEqual([ - { label: "Julius iPhone", scopes: ["orchestration:read"] }, - ]); - }); }); diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts deleted file mode 100644 index c5c0d89597aa..000000000000 --- a/apps/web/src/bootstrap.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { showBootError } from "./lib/bootError"; - -class BootElement extends EventTarget { - children: BootElement[] = []; - textContent = ""; - - constructor(readonly tagName: string) { - super(); - } - - setAttribute() {} - - append(child: BootElement) { - this.children.push(child); - } - - replaceChildren(...children: BootElement[]) { - this.children = children; - } - - get text(): string { - return this.textContent + this.children.map((child) => child.text).join(" "); - } -} - -describe("app startup failures", () => { - let bootShell: BootElement | null; - - beforeEach(() => { - vi.resetModules(); - bootShell = new BootElement("div"); - vi.stubGlobal("document", { - getElementById: () => bootShell, - createElement: (tagName: string) => new BootElement(tagName), - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - }); - - it("shows failures from asynchronous app startup", async () => { - vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); - - await import("./bootstrap"); - await vi.dynamicImportSettled(); - - expect(bootShell?.text).toContain("Startup chunks failed"); - }); - - afterEach(() => { - vi.doUnmock("./main"); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - }); - - it("replaces the splash when an app import throws before main can run", async () => { - vi.doMock("./main", () => { - throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); - }); - const reload = vi.fn(); - vi.stubGlobal("window", { location: { reload } }); - - await import("./bootstrap"); - await vi.dynamicImportSettled(); - - expect(bootShell?.text).toContain("T3 Code could not load."); - const reloadButton = bootShell?.children[0]?.children.find( - (element) => element.tagName === "button", - ); - expect(reloadButton?.text).toBe("Reload"); - reloadButton?.dispatchEvent(new Event("click")); - expect(reload).toHaveBeenCalledOnce(); - }); - - it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { - vi.stubEnv("DEV", dev); - - showBootError(new Error("internal module path")); - - expect(bootShell?.text).toContain("T3 Code could not load."); - expect(bootShell?.text.includes("internal module path")).toBe(dev); - }); - - it("does not replace the app after React removes the splash", () => { - bootShell = null; - const createElement = vi.spyOn(document, "createElement"); - - showBootError(new Error("late failure")); - - expect(createElement).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts deleted file mode 100644 index e1c87bcf0595..000000000000 --- a/apps/web/src/branding.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - resolveServerBackedAppDisplayName, - resolveServerBackedAppStageLabel, -} from "./branding.logic"; - -const originalWindow = globalThis.window; - -afterEach(() => { - vi.resetModules(); - - if (originalWindow === undefined) { - Reflect.deleteProperty(globalThis, "window"); - return; - } - - globalThis.window = originalWindow; -}); - -describe("branding", () => { - it("uses injected desktop branding when available", async () => { - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - desktopBridge: { - getAppBranding: () => ({ - baseName: "T3 Code", - stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", - }), - }, - }, - }); - - const branding = await import("./branding"); - - expect(branding.APP_BASE_NAME).toBe("T3 Code"); - expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); - }); - - it("normalizes hosted app channel metadata", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); - expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); - }); - - it("does not label the latest hosted app channel", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "latest"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBe("latest"); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Latest"); - expect(branding.APP_STAGE_LABEL).toBe("Latest"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code"); - }); - - it("ignores unknown hosted app channels", async () => { - vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); - - const branding = await import("./branding"); - - expect(branding.HOSTED_APP_CHANNEL).toBeNull(); - expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); - }); -}); - -describe("branding logic", () => { - it("returns Nightly for nightly primary server versions", () => { - expect( - resolveServerBackedAppStageLabel({ - primaryServerVersion: "0.0.28-nightly.20260616.12", - fallbackStageLabel: "Alpha", - }), - ).toBe("Nightly"); - }); - - it("updates the display name for nightly primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.28-nightly.20260616.12", - }), - ).toBe("T3 Code (Nightly)"); - }); - - it("keeps the fallback display name for stable primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.27", - }), - ).toBe("T3 Code (Alpha)"); - }); - - it("keeps the fallback display name for malformed nightly primary server versions", () => { - expect( - resolveServerBackedAppDisplayName({ - baseName: "T3 Code", - fallbackDisplayName: "T3 Code (Alpha)", - fallbackStageLabel: "Alpha", - primaryServerVersion: "0.0.28-nightly.20260616", - }), - ).toBe("T3 Code (Alpha)"); - }); -}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts deleted file mode 100644 index 087b5d109b40..000000000000 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; - -describe("commitViewportAndAspectRatio", () => { - it("commits the aspect ratio only after the viewport succeeds", async () => { - let resolveChange: (() => void) | undefined; - const onChange = vi.fn( - () => - new Promise((resolve) => { - resolveChange = resolve; - }), - ); - const onAspectRatioChange = vi.fn(); - const setting: PreviewViewportSetting = { _tag: "freeform", width: 900, height: 600 }; - - const commit = commitViewportAndAspectRatio(setting, 1.5, onChange, onAspectRatioChange); - expect(onAspectRatioChange).not.toHaveBeenCalled(); - - resolveChange?.(); - await commit; - expect(onAspectRatioChange).toHaveBeenCalledWith(1.5); - }); - - it("keeps the previous aspect ratio when the viewport commit fails", async () => { - const onAspectRatioChange = vi.fn(); - await expect( - commitViewportAndAspectRatio( - { _tag: "fill" }, - null, - async () => Promise.reject(new Error("resize failed")), - onAspectRatioChange, - ), - ).rejects.toThrow("resize failed"); - expect(onAspectRatioChange).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index 390c77de4424..fbd843c8d43f 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 26aa15c08fae..0128a203b208 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useLayoutEffect, useRef } from "react"; import { acquireBrowserSurface } from "./browserSurfaceStore"; diff --git a/apps/web/src/browser/BrowserViewportResizeHandles.tsx b/apps/web/src/browser/BrowserViewportResizeHandles.tsx index 08e69c359a15..1c621e0070fb 100644 --- a/apps/web/src/browser/BrowserViewportResizeHandles.tsx +++ b/apps/web/src/browser/BrowserViewportResizeHandles.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, diff --git a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx index 4066d19d77e2..29c217e064ad 100644 --- a/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.browser.test.tsx @@ -7,9 +7,9 @@ import { type DesktopPreviewBridge, type PreviewSessionSnapshot, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, fakeTimers, render } from "../../test/render"; const mocks = vi.hoisted(() => ({ getClientSettings: vi.fn<() => Promise>(), @@ -73,10 +73,7 @@ const snapshot = (tabId: string, updatedAt: string): PreviewSessionSnapshot => ( updatedAt, }); -let root: Root | undefined; -let container: HTMLDivElement; - -beforeEach(() => { +function setup(): Disposable { __resetClientSettingsPersistenceForTests(); mocks.getClientSettings.mockReset().mockResolvedValue(DEFAULT_CLIENT_SETTINGS); mocks.setClientSettings.mockReset().mockResolvedValue(undefined); @@ -88,26 +85,37 @@ beforeEach(() => { webPreferences: "contextIsolation=yes", preloadUrl: null, }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); -}); - -afterEach(async () => { - vi.useFakeTimers(); - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - resetPreviewStateForTests(); - __resetClientSettingsPersistenceForTests(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return { + [Symbol.dispose]() { + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + }, + }; +} + +async function renderHost() { + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + + + , + ), + ); + // Unmounting releases each tab on a close timer; run it now so it never fires in a later test. + stack.defer(async () => { + using _timers = fakeTimers(); + await screen.unmount(); + await vi.advanceTimersByTimeAsync(0); + }); + await act(() => ensureClientSettingsHydrated()); + const environment = stack.move(); + return { container: screen.container, [Symbol.asyncDispose]: () => environment.disposeAsync() }; +} describe("ElectronBrowserHost", () => { it("never moves a tab's webview when the server reorders tabs by last update", async () => { + using _state = setup(); reconcilePreviewServerSessions(threadRef, { sessions: [ snapshot("tab_7", "2026-01-01T00:00:01.000Z"), @@ -116,17 +124,9 @@ describe("ElectronBrowserHost", () => { serverEpoch: "server-a", revision: 1, }); - root = createRoot(container); - await act(async () => { - root!.render( - - - , - ); - await ensureClientSettingsHydrated(); - }); + await using screen = await renderHost(); - const host = container.querySelector("[data-electron-browser-host]")!; + const host = screen.container.querySelector("[data-electron-browser-host]")!; const webviews = () => [...host.querySelectorAll("webview")]; const before = webviews(); expect(before.map((node) => node.getAttribute("data-preview-server-tab")).toSorted()).toEqual([ diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 78b4d579c71f..8818b377f5e3 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -1,5 +1,3 @@ -"use client"; - import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { AuthPreviewOperateScope, diff --git a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx b/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx deleted file mode 100644 index d9a768448bdf..000000000000 --- a/apps/web/src/browser/HostedBrowserWebview.browser.test.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { - DEFAULT_CLIENT_SETTINGS, - EnvironmentId, - FILL_PREVIEW_VIEWPORT, - ThreadId, - type ClientSettings, - type DesktopPreviewBridge, - type PreviewViewportSetting, -} from "@t3tools/contracts"; -import { act } from "react"; - -import "~/index.css"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { previewBridge } from "~/components/preview/previewBridge"; - -const mocks = vi.hoisted(() => ({ - getClientSettings: vi.fn<() => Promise>(), - setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), - createTab: vi.fn(), - closeTab: vi.fn(), - registerWebview: vi.fn(), - getPreviewConfig: vi.fn(), - activeRecordings: new Set(), -})); - -vi.mock(import("~/localApi"), async (original) => { - const actual = await original(); - return { - ...actual, - ensureLocalApi: () => ({ ...actual.ensureLocalApi(), persistence: mocks as never }), - }; -}); - -vi.mock(import("./desktopTabLifetime"), { spy: true }); - -// The Electron desktop bridge; a browser has none. -vi.mock(import("~/components/preview/previewBridge"), async (original) => ({ - ...(await original()), - previewBridge: { - createTab: mocks.createTab, - closeTab: mocks.closeTab, - registerWebview: mocks.registerWebview, - getPreviewConfig: mocks.getPreviewConfig, - } as unknown as NonNullable, -})); - -vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({ - ...(await original()), - usePreviewBridge: () => undefined, -})); - -import { - __resetClientSettingsPersistenceForTests, - ensureClientSettingsHydrated, -} from "~/hooks/useSettings"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import * as desktopTabLifetime from "./desktopTabLifetime"; -import { HostedBrowserWebview } from "./HostedBrowserWebview"; - -let root: Root | undefined; -let container: HTMLDivElement; - -function deferred() { - let resolve!: (value: A) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -beforeEach(() => { - __resetClientSettingsPersistenceForTests(); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); - mocks.getClientSettings.mockReset(); - mocks.setClientSettings.mockReset().mockResolvedValue(undefined); - mocks.createTab.mockReset().mockResolvedValue(undefined); - mocks.closeTab.mockReset().mockResolvedValue(undefined); - mocks.registerWebview.mockReset().mockResolvedValue(undefined); - mocks.getPreviewConfig.mockReset().mockResolvedValue({ - partition: "persist:t3-preview-work", - webPreferences: "contextIsolation=yes", - preloadUrl: null, - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(console, "error").mockImplementation(() => undefined); - container = document.createElement("div"); - document.body.append(container); -}); - -afterEach(async () => { - vi.useFakeTimers(); - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - await vi.advanceTimersByTimeAsync(0); - vi.useRealTimers(); - __resetClientSettingsPersistenceForTests(); - useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("HostedBrowserWebview settings hydration", () => { - it("starts a retained background tab only after a settings read succeeds on retry", async () => { - const firstRead = deferred(); - const retryRead = deferred(); - const tabCreation = deferred(); - mocks.getClientSettings - .mockReturnValueOnce(firstRead.promise) - .mockReturnValueOnce(retryRead.promise); - mocks.createTab.mockReturnValueOnce(tabCreation.promise); - const acquire = vi.mocked(desktopTabLifetime.acquireDesktopTab); - // Chromium treats as an unknown element; give it the one Electron method the - // host calls once the guest attaches. - const createElement = document.createElement.bind(document); - const createGuest = vi.spyOn(document, "createElement").mockImplementation((( - tagName: string, - options?: ElementCreationOptions, - ) => { - const element = createElement(tagName, options); - return tagName === "webview" - ? Object.assign(element, { getWebContentsId: () => 41 }) - : element; - }) as typeof document.createElement); - const guests = () => createGuest.mock.calls.filter(([tagName]) => tagName === "webview"); - const guest = () => container.querySelector("webview"); - const threadRef = { - environmentId: EnvironmentId.make("host-settings-retry"), - threadId: ThreadId.make("thread-settings-retry"), - }; - const runtimeTabId = "retained-background-tab"; - useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); - - root = createRoot(container); - await act(async () => - root!.render( - , - ), - ); - - expect(mocks.getClientSettings).toHaveBeenCalledOnce(); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - const failure = new Error("Saved settings are unavailable"); - await act(async () => { - const hydration = ensureClientSettingsHydrated(); - firstRead.reject(failure); - await expect(hydration).rejects.toBe(failure); - }); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - let retry!: Promise; - await act(() => { - retry = ensureClientSettingsHydrated(); - }); - expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); - expect(acquire).not.toHaveBeenCalled(); - expect(guest()).toBeNull(); - expect(mocks.createTab).not.toHaveBeenCalled(); - - await act(async () => { - retryRead.resolve({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.25, - browserDefaultAppearance: "dark", - browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], - browserDefaultProfileId: "work", - }); - await retry; - }); - - expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId, undefined); - expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); - expect(guests()).toHaveLength(1); - expect(guest()?.getAttribute("partition")).toBe("persist:t3-preview-work"); - expect(guest()?.getAttribute("src")).toBe("https://example.com"); - expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { - zoomFactor: 1.25, - colorScheme: "dark", - }); - expect(mocks.registerWebview).not.toHaveBeenCalled(); - - await act(async () => { - tabCreation.resolve(); - await tabCreation.promise; - }); - expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); - expect(mocks.closeTab).not.toHaveBeenCalled(); - expect(mocks.setClientSettings).not.toHaveBeenCalled(); - }); -}); - -describe("HostedBrowserWebview viewport layout", () => { - const runtimeTabId = "zoom-layout-tab"; - const owner = Symbol("panel"); - const panel = { x: 0, y: 0, width: 2000, height: 1600 }; - - async function renderViewport(viewport: PreviewViewportSetting) { - mocks.getClientSettings.mockResolvedValue({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.5, - }); - root ??= createRoot(container); - await act(async () => { - await ensureClientSettingsHydrated(); - root!.render( - , - ); - }); - const guest = container.querySelector("webview"); - expect(guest).not.toBeNull(); - return guest!; - } - - it("keeps fixed viewports sized in guest pixels across host zoom and fitted surfaces", async () => { - let windowZoom = 1.25; - vi.stubGlobal("desktopBridge", { getWindowZoomFactor: () => windowZoom }); - useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); - useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30); - const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 960, height: 720 }); - - windowZoom = 0.75; - await act(() => window.dispatchEvent(new Event("resize"))); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 1600, height: 1200 }); - - windowZoom = 1; - await act(() => window.dispatchEvent(new Event("resize"))); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); - - windowZoom = 1.25; - await act(() => window.dispatchEvent(new Event("resize"))); - await renderViewport({ _tag: "preset", presetId: "iphone-se", width: 375, height: 667 }); - expect(guest.getBoundingClientRect().width).toBe(450); - expect(guest.getBoundingClientRect().height).toBeCloseTo(800.4, 1); - - const miniPlayer = Symbol("mini-player"); - await act(() => { - useBrowserSurfaceStore.getState().claim(runtimeTabId, miniPlayer, true); - useBrowserSurfaceStore - .getState() - .present(runtimeTabId, miniPlayer, { x: 0, y: 0, width: 225, height: 401 }, true, 0, 48); - }); - expect(guest.offsetWidth).toBe(450); - expect(guest.offsetHeight).toBe(800); - expect(guest.getBoundingClientRect().width).toBe(225); - expect(guest.getBoundingClientRect().height).toBeCloseTo(400.2, 1); - - await act(() => { - useBrowserSurfaceStore.getState().claim(runtimeTabId, owner, false); - useBrowserSurfaceStore.getState().present(runtimeTabId, owner, panel, true, 0, 30); - }); - await renderViewport(FILL_PREVIEW_VIEWPORT); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 2000, height: 1600 }); - expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { - zoomFactor: 1.5, - colorScheme: DEFAULT_CLIENT_SETTINGS.browserDefaultAppearance, - }); - }); - - it("preserves preview scaling with an older bridge or no desktop bridge", async () => { - vi.stubGlobal("desktopBridge", {}); - const guest = await renderViewport({ _tag: "freeform", width: 800, height: 600 }); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); - - vi.stubGlobal("desktopBridge", undefined); - await act(() => window.dispatchEvent(new Event("resize"))); - expect(guest.getBoundingClientRect()).toMatchObject({ width: 1200, height: 900 }); - }); -}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 7d6c86d77409..4dc393ffa4d8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { DesktopPreviewColorScheme, PreviewViewportSetting, diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 27ae15e5e798..c0de4f0d32ea 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -1,5 +1,3 @@ -"use client"; - import { createPreviewFramePainter, createPreviewStreamClient, diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index 5384d093b883..df371a83b317 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; - const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ @@ -11,7 +9,7 @@ vi.mock("~/hooks/useSettings", () => ({ ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -26,36 +24,9 @@ const withDefaultProfile = (browserDefaultProfileId: string) => { }; describe("getBrowserDefaults profile resolution", () => { - it("keeps a configured persistent profile", () => { - expect(withDefaultProfile("work").profileId).toBe("work"); - }); - - it("falls back for an unknown profile", () => { - expect(withDefaultProfile("deleted").profileId).toBe(DEFAULT_BROWSER_PROFILE_ID); - }); - it("refuses incognito as the default", () => { expect(withDefaultProfile(INCOGNITO_BROWSER_PROFILE_ID).profileId).toBe( DEFAULT_BROWSER_PROFILE_ID, ); }); }); - -describe("resolveBrowserDefaults", () => { - it("rejects failed reads and uses the saved profile after a successful retry", async () => { - withDefaultProfile("work"); - settings.current.browserDefaultZoomFactor = 1.25; - settings.current.browserDefaultAppearance = "dark"; - const failure = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); - - await expect(resolveBrowserDefaults()).rejects.toBe(failure); - await expect(resolveBrowserDefaults()).resolves.toMatchObject({ - viewport: { _tag: "fill" }, - zoomFactor: 1.25, - appearance: "dark", - autoShowFloatingPreview: true, - profileId: "work", - }); - }); -}); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 7747898d0818..55bf7c3be8ee 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,9 +1,7 @@ import type { BrowserLinkTarget } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; - -import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; +import { resolveLinkTarget } from "./browserLinkTarget"; const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); @@ -15,66 +13,6 @@ vi.mock("~/hooks/useSettings", () => ({ const click = { metaKey: false, ctrlKey: false }; describe("resolveLinkTarget", () => { - it("keeps the system browser unless the user asked for in-app", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "system", - canOpenInApp: true, - }), - ).toBe("system"); - }); - - it("opens in-app when asked and the runtime can", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "app", - canOpenInApp: true, - }), - ).toBe("app"); - }); - - it("falls back to the system browser where there is no in-app browser", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: click, - preference: "app", - canOpenInApp: false, - }), - ).toBe("system"); - }); - - it("treats a modifier click as the way out of the in-app default", () => { - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: true, ctrlKey: false }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: false, ctrlKey: true }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - expect( - resolveLinkTarget({ - url: "https://example.com/", - event: { metaKey: false, ctrlKey: false, button: 1 }, - preference: "app", - canOpenInApp: true, - }), - ).toBe("system"); - }); - it("leaves non-web schemes to the shell", () => { for (const url of ["mailto:someone@example.com", "vscode://file/x", "not a url"]) { expect(resolveLinkTarget({ url, event: click, preference: "app", canOpenInApp: true })).toBe( @@ -83,17 +21,3 @@ describe("resolveLinkTarget", () => { } }); }); - -describe("resolveBrowserLinkTargetPreference", () => { - it.each(["system", "app"] as const)( - "rejects failed reads instead of using the current %s preference", - async (preference) => { - settings.browserLinkTarget = preference; - const failure = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); - - await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); - await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); - }, - ); -}); diff --git a/apps/web/src/browser/browserPointerStore.test.ts b/apps/web/src/browser/browserPointerStore.test.ts deleted file mode 100644 index de9c173dc2da..000000000000 --- a/apps/web/src/browser/browserPointerStore.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; - -import { useBrowserPointerStore } from "./browserPointerStore"; - -beforeEach(() => { - useBrowserPointerStore.setState({ byTabId: {} }); -}); - -describe("browserPointerStore", () => { - it("tracks the latest pointer target independently for each tab", () => { - const store = useBrowserPointerStore.getState(); - store.apply({ - tabId: "tab_a", - phase: "move", - x: 20, - y: 30, - sequence: 0, - createdAt: "2026-06-12T00:00:00.000Z", - }); - store.apply({ - tabId: "tab_b", - phase: "move", - x: 40, - y: 50, - sequence: 1, - createdAt: "2026-06-12T00:00:01.000Z", - }); - store.apply({ - tabId: "tab_a", - phase: "click", - x: 60, - y: 70, - sequence: 2, - createdAt: "2026-06-12T00:00:02.000Z", - }); - - expect(useBrowserPointerStore.getState().byTabId).toMatchObject({ - tab_a: { phase: "click", x: 60, y: 70, sequence: 2 }, - tab_b: { phase: "move", x: 40, y: 50, sequence: 1 }, - }); - }); - - it("clears one tab without affecting the others", () => { - const store = useBrowserPointerStore.getState(); - store.apply({ - tabId: "tab_a", - phase: "move", - x: 20, - y: 30, - sequence: 0, - createdAt: "2026-06-12T00:00:00.000Z", - }); - store.apply({ - tabId: "tab_b", - phase: "move", - x: 40, - y: 50, - sequence: 1, - createdAt: "2026-06-12T00:00:01.000Z", - }); - - store.clear("tab_a"); - - expect(useBrowserPointerStore.getState().byTabId).toEqual({ - tab_b: expect.objectContaining({ x: 40, y: 50 }), - }); - }); -}); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 02408846d060..67b66ba4bade 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -2,14 +2,9 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, EnvironmentId, ThreadId, - PreviewCaptureFailure, type DesktopPreviewBridge, - type DesktopPreviewScreenshotArtifact, - type PreviewCaptureFailureEncoded, } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from "vite-plus/test"; - -import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const { captureScreenshot, @@ -84,12 +79,7 @@ vi.mock("~/hooks/useSettings", () => ({ })); import { - BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, - BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, - BrowserRecordingCaptureTimeoutError, BrowserRecordingConflictError, - BrowserRecordingFormatUnavailableError, - BrowserRecordingStartCancelledError, findActiveBrowserRecordingRuntimeTabId, readActiveBrowserRecordingTabIds, readActiveBrowserRecordingTargets, @@ -98,7 +88,6 @@ import { stopBrowserRecordingForUpload, } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; -import { previewBridge } from "~/components/preview/previewBridge"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; class FakeMediaRecorder { @@ -183,59 +172,6 @@ describe("browser recording", () => { vi.unstubAllGlobals(); }); - it("decodes screenshot failures and preserves successful capture types and artifacts", async () => { - expectTypeOf>().toEqualTypeOf< - Promise - >(); - expectTypeOf>().toEqualTypeOf< - Promise - >(); - expectTypeOf< - ReturnType["captureScreenshot"]> - >().toEqualTypeOf>(); - expectTypeOf< - ReturnType["recording"]["startScreencast"]> - >().toEqualTypeOf>(); - captureScreenshot.mockResolvedValueOnce({ - _tag: "PreviewCaptureFailure", - reason: "capture-timeout", - stage: "capture-page", - }); - await expect(previewBridge!.captureScreenshot("capture-tab")).rejects.toMatchObject({ - reason: "capture-timeout", - stage: "capture-page", - message: expect.stringContaining("timed out during capture-page"), - }); - - const artifact: DesktopPreviewScreenshotArtifact = { - id: "screenshot-test", - tabId: "capture-tab", - path: "/tmp/capture.png", - mimeType: "image/png", - sizeBytes: 100, - createdAt: "2026-06-26T00:00:00.000Z", - }; - captureScreenshot.mockResolvedValueOnce(artifact); - expect(await previewBridge!.captureScreenshot("capture-tab")).toBe(artifact); - expect(captureScreenshot).toHaveBeenNthCalledWith(2, "capture-tab"); - }); - - it("starts recording for a visible tab", async () => { - await startBrowserRecording("recording-tab"); - const startupEvents = [...events]; - - await stopBrowserRecording("recording-tab"); - expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); - }); - - it("routes gesture-free starts through the desktop capture trigger", async () => { - await startBrowserRecording("automation-recording-tab"); - - expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledOnce(); - await stopBrowserRecording("automation-recording-tab"); - }); - it("saves locally and releases capture before transferring the encoded recording once", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValue({ @@ -278,109 +214,6 @@ describe("browser recording", () => { await stopBrowserRecording("failed-transfer-tab"); }); - it("paints and holds a hidden browser surface for the recording lifetime", async () => { - startScreencast.mockImplementationOnce(async (tabId: string) => { - expect(animationFrameCount).toBe(2); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); - }); - getDisplayMedia.mockImplementationOnce(async () => { - expect(animationFrameCount).toBe(2); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); - return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] }; - }); - - await startBrowserRecording("background-tab"); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); - - await stopBrowserRecording("background-tab"); - expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBeUndefined(); - }); - - it("bounds compositor warmup when animation frames are paused", async () => { - vi.useFakeTimers(); - const cancelAnimationFrame = vi.fn(); - vi.stubGlobal( - "requestAnimationFrame", - vi.fn(() => 42), - ); - vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); - - const startPromise = startBrowserRecording("hidden-window-tab"); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS); - - await startPromise; - expect(cancelAnimationFrame).toHaveBeenCalledWith(42); - await stopBrowserRecording("hidden-window-tab"); - }); - - it.each([ - { width: 1280, height: 720, frameRate: 60, bitrate: 2_764_800 }, - { width: 320, height: 240, frameRate: 30, bitrate: 2_500_000 }, - { width: 3840, height: 2160, frameRate: 60, bitrate: 24_883_200 }, - { width: 7680, height: 4320, frameRate: 60, bitrate: 50_000_000 }, - ])("records the native $width x $height stream at $frameRate fps", async (settings) => { - const stopTrack = vi.fn(); - const stream = { - getVideoTracks: () => [{ getSettings: () => settings }], - getTracks: () => [{ stop: stopTrack }], - } as unknown as MediaStream; - getDisplayMedia.mockResolvedValueOnce(stream); - - await startBrowserRecording("recording-tab"); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 30, max: 30 } }, - }); - expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); - expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate); - - await stopBrowserRecording("recording-tab"); - expect(stopTrack).toHaveBeenCalledOnce(); - expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!); - }); - - it("uses the configured recording frame rate", async () => { - clientSettings.browserRecordingFrameRate = 60; - - await startBrowserRecording("recording-tab"); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 60, max: 60 } }, - }); - await stopBrowserRecording("recording-tab"); - }); - - it("clears a failed settings read before retrying recording", async () => { - const tabId = "settings-read-failure-tab"; - const error = new Error("Settings read failed"); - vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); - - await expect(startBrowserRecording(tabId)).rejects.toBe(error); - - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); - expect(animationFrameCount).toBe(0); - expect(startScreencast).not.toHaveBeenCalled(); - expect(stopScreencast).not.toHaveBeenCalled(); - expect(getDisplayMedia).not.toHaveBeenCalled(); - expect(FakeMediaRecorder.instances).toHaveLength(0); - - clientSettings.browserRecordingFrameRate = 60; - await startBrowserRecording(tabId); - - expect(getDisplayMedia).toHaveBeenCalledWith({ - audio: false, - video: { frameRate: { ideal: 60, max: 60 } }, - }); - await stopBrowserRecording(tabId); - - expect(startScreencast).toHaveBeenCalledOnce(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); - }); - it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ @@ -400,57 +233,6 @@ describe("browser recording", () => { expect(stopTrack).toHaveBeenCalledOnce(); }); - it("uses the best supported encoder and saves the recorder's actual format", async () => { - FakeMediaRecorder.supportedTypes = new Set([ - "video/mp4;codecs=avc1", - "video/mp4;codecs=avc1.42e01e", - "video/webm;codecs=vp9", - "video/webm;codecs=av1", - ]); - FakeMediaRecorder.outputMimeType = "video/webm;codecs=av01"; - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - - expect(FakeMediaRecorder.instances[0]?.options).toEqual({ - mimeType: "video/mp4;codecs=avc1", - videoBitsPerSecond: 3_110_400, - }); - expect(save).toHaveBeenCalledWith( - "recording-tab", - "video/webm;codecs=av01", - expect.any(Uint8Array), - ); - }); - - it("lets the browser select the format when no preferred encoding is supported", async () => { - FakeMediaRecorder.supportedTypes = new Set(); - FakeMediaRecorder.outputMimeType = "video/platform-default"; - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - - expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 }); - expect(save).toHaveBeenCalledWith( - "recording-tab", - "video/platform-default", - expect.any(Uint8Array), - ); - }); - - it("reports when MediaRecorder provides no output format", async () => { - FakeMediaRecorder.supportedTypes = new Set(); - FakeMediaRecorder.outputMimeType = ""; - - await startBrowserRecording("recording-tab"); - - await expect(stopBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingFormatUnavailableError, - ); - expect(save).not.toHaveBeenCalled(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - }); - it("releases the native capture lease when stream acquisition fails", async () => { const cause = new Error("capture failed"); cause.name = "NotReadableError"; @@ -467,95 +249,6 @@ describe("browser recording", () => { expect(events.at(-1)).toBe("clear"); }); - it.each([false, true])( - "explains real video-source rejection and permits retry when cleanup fails: %s", - async (cleanupFails) => { - const cause = new DOMException("Could not start video source", "NotReadableError"); - const cleanupCause = new Error("native stop failed"); - getDisplayMedia.mockRejectedValueOnce(cause); - if (cleanupFails) stopScreencast.mockRejectedValueOnce(cleanupCause); - - const error = await startBrowserRecording("recording-tab").catch( - (failure: unknown) => failure, - ); - expect(error).toMatchObject({ - _tag: "BrowserRecordingOperationError", - operation: "capture-media-stream", - tabId: "recording-tab", - message: - "Unable to start the preview video source. Make sure the capture host is unlocked " + - "and its display is on, then retry. NotReadableError: Could not start video source", - cause: cleanupFails ? { cause, errors: [cause, cleanupCause] } : cause, - }); - expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); - expect(FakeMediaRecorder.instances).toHaveLength(0); - expect(save).not.toHaveBeenCalled(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledTimes(2); - }, - ); - - it("reports a native arm diagnostic immediately and frees the tab for a retry", async () => { - startScreencast.mockResolvedValueOnce({ - _tag: "PreviewCaptureFailure", - reason: "renderer-unavailable", - stage: "recording-start", - }); - - await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ - _tag: "BrowserRecordingOperationError", - operation: "start-screencast", - tabId: "recording-tab", - cause: expect.any(PreviewCaptureFailure), - message: "The preview renderer is unavailable. Reopen the preview tab before retrying.", - }); - expect(getDisplayMedia).not.toHaveBeenCalled(); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledOnce(); - }); - - it("times out stalled stream acquisition and stops a late stream", async () => { - vi.useFakeTimers(); - let finishCapture!: (stream: MediaStream) => void; - const stopTrack = vi.fn(); - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishCapture = resolve; - }), - ); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const rejection = expect(startPromise).rejects.toMatchObject({ - _tag: "BrowserRecordingCaptureTimeoutError", - tabId: "recording-tab", - timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, - }); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); - - await rejection; - await expect(startPromise).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); - expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); - expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); - expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); - - finishCapture({ - getVideoTracks: () => [], - getTracks: () => [{ stop: stopTrack }], - } as unknown as MediaStream); - await vi.advanceTimersByTimeAsync(0); - expect(stopTrack).toHaveBeenCalledOnce(); - }); - it("records separate tabs concurrently", async () => { const firstThreadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -585,133 +278,6 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledTimes(2); }); - it("serializes display media grants for concurrent recording starts", async () => { - let finishFirstCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirstCapture = resolve; - }), - ) - .mockResolvedValueOnce(stream); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const secondStart = startBrowserRecording("recording-tab-2"); - await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); - - expect(startScreencast).toHaveBeenCalledTimes(1); - finishFirstCapture(stream); - await Promise.all([firstStart, secondStart]); - - expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); - expect(getDisplayMedia).toHaveBeenCalledTimes(2); - await Promise.all([ - stopBrowserRecording("recording-tab"), - stopBrowserRecording("recording-tab-2"), - ]); - }); - - it("cancels a queued recording when stopped before its media grant", async () => { - let finishFirstCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirstCapture = resolve; - }), - ); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - const secondStart = startBrowserRecording("recording-tab-2"); - await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); - - const secondStop = stopBrowserRecording("recording-tab-2"); - await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); - await expect(secondStop).resolves.toBeNull(); - expect(startScreencast).toHaveBeenCalledTimes(1); - - finishFirstCapture(stream); - await firstStart; - await stopBrowserRecording("recording-tab"); - expect(getDisplayMedia).toHaveBeenCalledOnce(); - }); - - it("latches a stop that arrives before the start becomes queued", async () => { - let releaseDelayedPaint!: (timestamp: number) => void; - let frameId = 0; - vi.stubGlobal( - "requestAnimationFrame", - vi.fn((callback: FrameRequestCallback) => { - frameId += 1; - if (frameId === 1) releaseDelayedPaint = callback; - else callback(frameId); - return frameId; - }), - ); - let finishBlockingCapture!: (stream: MediaStream) => void; - const stream = { - getVideoTracks: () => [], - getTracks: () => [{ stop: vi.fn() }], - } as unknown as MediaStream; - getDisplayMedia.mockImplementationOnce( - () => - new Promise((resolve) => { - finishBlockingCapture = resolve; - }), - ); - - const delayedStart = startBrowserRecording("delayed-tab"); - await vi.waitFor(() => - expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), - ); - const blockingStart = startBrowserRecording("blocking-tab"); - await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); - - const delayedStop = stopBrowserRecording("delayed-tab"); - releaseDelayedPaint(1); - - await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); - await expect(delayedStop).resolves.toBeNull(); - expect(startScreencast).toHaveBeenCalledOnce(); - - finishBlockingCapture(stream); - await blockingStart; - await stopBrowserRecording("blocking-tab"); - }); - - it("finishes an uncontended pre-grant start before stopping", async () => { - const animationFrames: FrameRequestCallback[] = []; - vi.stubGlobal( - "requestAnimationFrame", - vi.fn((callback: FrameRequestCallback) => { - animationFrames.push(callback); - return animationFrames.length; - }), - ); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => - expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), - ); - const stopPromise = stopBrowserRecording("recording-tab"); - expect(startScreencast).not.toHaveBeenCalled(); - - animationFrames.shift()?.(1); - animationFrames.shift()?.(2); - await startPromise; - await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); - }); - it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-recording"), @@ -735,188 +301,4 @@ describe("browser recording", () => { await stopBrowserRecording(runtimeTabId); }); - - it("does not report success for a second start while the first is still starting", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await firstStart; - await stopBrowserRecording("recording-tab"); - }); - - it("does not report success for a start while the recording is stopping", async () => { - let finishStoppingScreencast: (() => void) | undefined; - stopScreencast.mockImplementationOnce(async () => { - await new Promise((resolve) => { - finishStoppingScreencast = resolve; - }); - return undefined; - }); - - await startBrowserRecording("recording-tab"); - const stopPromise = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); - - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStoppingScreencast?.(); - await stopPromise; - }); - - it("shares an in-progress stop with duplicate callers", async () => { - let finishStoppingScreencast: (() => void) | undefined; - stopScreencast.mockImplementationOnce(async () => { - await new Promise((resolve) => { - finishStoppingScreencast = resolve; - }); - return undefined; - }); - - await startBrowserRecording("recording-tab"); - const firstStop = stopBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce()); - const duplicateStop = stopBrowserRecording("recording-tab"); - - finishStoppingScreencast?.(); - const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]); - - expect(duplicateArtifact).toEqual(firstArtifact); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).toHaveBeenCalledOnce(); - }); - - it("finishes startup before stopping so an active recording yields an artifact", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - expect(stopScreencast).not.toHaveBeenCalled(); - finishStartingScreencast?.(); - - await startPromise; - await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).toHaveBeenCalledOnce(); - expect(events.at(-1)).toBe("clear"); - }); - - it("does not release the recording slot until a cancelled start settles", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab")); - await new Promise((resolve) => setTimeout(resolve, 0)); - const startCallsBeforeFirstSettled = startScreencast.mock.calls.length; - - finishStartingScreencast?.(); - await firstStart; - await stopPromise; - await restartAfterStop; - await stopBrowserRecording("recording-tab"); - - expect(startCallsBeforeFirstSettled).toBe(1); - }); - - it("keeps the recording slot while a failed stop waits for startup", async () => { - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); - - const firstStart = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - const rejectedStop = expect(stopPromise).rejects.toMatchObject({ - operation: "stop-screencast", - tabId: "recording-tab", - }); - expect(stopScreencast).not.toHaveBeenCalled(); - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await firstStart; - await rejectedStop; - expect(stopScreencast).toHaveBeenCalledOnce(); - - await startBrowserRecording("recording-tab"); - await stopBrowserRecording("recording-tab"); - }); - - it("fails a stop that waits too long for startup without freeing the recording slot", async () => { - vi.useFakeTimers(); - let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - await new Promise((resolve) => { - finishStartingScreencast = resolve; - }); - }); - - const startPromise = startBrowserRecording("recording-tab"); - await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); - - const stopPromise = stopBrowserRecording("recording-tab"); - await vi.advanceTimersByTimeAsync(0); - expect(stopScreencast).not.toHaveBeenCalled(); - - const rejection = expect(stopPromise).rejects.toMatchObject({ - operation: "wait-startup", - tabId: "recording-tab", - }); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); - - await rejection; - expect(save).not.toHaveBeenCalled(); - await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf( - BrowserRecordingConflictError, - ); - - finishStartingScreencast?.(); - await vi.advanceTimersByTimeAsync(32); - await startPromise; - const cleanupResult = await stopBrowserRecording("recording-tab"); - expect(cleanupResult).toBeNull(); - expect(stopScreencast).toHaveBeenCalledOnce(); - expect(save).not.toHaveBeenCalled(); - expect(events.at(-1)).toBe("clear"); - }); }); diff --git a/apps/web/src/browser/browserRecordingScope.test.ts b/apps/web/src/browser/browserRecordingScope.test.ts deleted file mode 100644 index 304adf4f19e0..000000000000 --- a/apps/web/src/browser/browserRecordingScope.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveBrowserRecordingStopTarget } from "./browserRecordingScope"; - -describe("resolveBrowserRecordingStopTarget", () => { - it("stops the only active recording when the implicit browser target changed", () => { - expect(resolveBrowserRecordingStopTarget(new Set(["tab-recording"]), "tab-browsing")).toBe( - "tab-recording", - ); - }); - - it("prefers an implicit target that is actively recording", () => { - expect( - resolveBrowserRecordingStopTarget( - new Set(["tab-recording-a", "tab-recording-b"]), - "tab-recording-b", - ), - ).toBe("tab-recording-b"); - }); - - it("does not guess when multiple recordings are active and the implicit target is not one", () => { - expect( - resolveBrowserRecordingStopTarget( - new Set(["tab-recording-a", "tab-recording-b"]), - "tab-browsing", - ), - ).toBeNull(); - }); - - it("only stops an explicitly requested tab when that tab is recording", () => { - const activeTabIds = new Set(["tab-recording"]); - expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-browsing", "tab-recording")).toBe( - "tab-recording", - ); - expect(resolveBrowserRecordingStopTarget(activeTabIds, "tab-recording", "tab-browsing")).toBe( - null, - ); - }); - - it("returns null when no matching recording is active", () => { - expect(resolveBrowserRecordingStopTarget(new Set(), "tab-browsing")).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/browserRecordingScope.ts b/apps/web/src/browser/browserRecordingScope.ts deleted file mode 100644 index ffbcc19a5453..000000000000 --- a/apps/web/src/browser/browserRecordingScope.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function resolveBrowserRecordingStopTarget( - activeTabIds: ReadonlySet, - implicitTabId: string | null, - explicitTabId?: string, -): string | null { - if (explicitTabId !== undefined) { - return activeTabIds.has(explicitTabId) ? explicitTabId : null; - } - if (implicitTabId !== null && activeTabIds.has(implicitTabId)) { - return implicitTabId; - } - if (activeTabIds.size !== 1) return null; - return activeTabIds.values().next().value ?? null; -} diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 456377a4d641..2630d8fe0e3e 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, acquireBrowserSurfaceActivity, - resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, } from "./browserSurfaceStore"; @@ -52,86 +51,6 @@ describe("browserSurfaceStore", () => { fittedLease.release(); }); - it("freezes the first content dimensions when fitting starts before the browser is measured", () => { - const tabId = "pending-fitted-browser-surface"; - const fittedLease = acquireBrowserSurface(tabId, true); - const sourceContent = { - x: 0, - y: 0, - width: 1_280, - height: 720, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }; - - useBrowserSurfaceStore.getState().presentContent(tabId, sourceContent); - useBrowserSurfaceStore.getState().presentContent(tabId, { - ...sourceContent, - width: 320, - height: 180, - scale: 0.25, - }); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]?.fittedSourceContent).toEqual( - sourceContent, - ); - fittedLease.release(); - }); - - it("tracks content dimensions for a browser that has never been visible", () => { - const tabId = "hidden-browser-surface-content-test"; - useBrowserSurfaceStore.getState().presentContent(tabId, { - x: 0, - y: 0, - width: 393, - height: 852, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - rect: null, - visible: false, - content: { width: 393, height: 852 }, - }); - }); - - it("keeps a hidden background tab on its own last rect", () => { - const staleRect = { x: 0, y: 0, width: 500, height: 700 }; - const liveRect = { x: 10, y: 20, width: 900, height: 640 }; - expect( - resolveBrowserSurfacePanelRect( - { - hidden: { - rect: staleRect, - visible: false, - zIndex: 30, - content: null, - fittedSourceContent: null, - fitSourceContent: false, - cornerRadius: 0, - updatedAt: 1, - owner: null, - }, - active: { - rect: liveRect, - visible: true, - zIndex: 30, - content: null, - fittedSourceContent: null, - fitSourceContent: false, - cornerRadius: 0, - updatedAt: 2, - owner: null, - }, - }, - "hidden", - ), - ).toEqual(staleRect); - }); - it("ignores updates and releases from a stale surface lease", () => { const tabId = "leased-browser-surface"; const staleRect = { x: 0, y: 0, width: 500, height: 700 }; @@ -149,52 +68,4 @@ describe("browserSurfaceStore", () => { visible: true, }); }); - - it("hides a surface when its current lease is released", () => { - const tabId = "released-browser-surface"; - const lease = acquireBrowserSurface(tabId); - lease.present({ x: 10, y: 20, width: 900, height: 640 }, true); - - lease.release(); - lease.present({ x: 0, y: 0, width: 1, height: 1 }, true); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: false, - owner: null, - }); - }); - - it("keeps the requested layer with the active surface lease", () => { - const tabId = "layered-browser-surface"; - const lease = acquireBrowserSurface(tabId); - lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - visible: true, - zIndex: 48, - }); - }); - - it("clears fitted presentation state when its lease is released", () => { - const tabId = "released-fitted-browser-surface"; - const fittedLease = acquireBrowserSurface(tabId, true); - useBrowserSurfaceStore.getState().presentContent(tabId, { - x: 0, - y: 0, - width: 1_280, - height: 800, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }); - - fittedLease.release(); - - expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ - fittedSourceContent: null, - fitSourceContent: false, - owner: null, - visible: false, - }); - }); }); diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b6c9ea64b4..39afd5ec816b 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -43,22 +43,6 @@ describe("browser target resolver", () => { }); }); - it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://127.0.0.1:5999/", - }), - ).toEqual({ - requestedUrl: "http://127.0.0.1:5999/", - resolvedUrl: "http://127.0.0.1:5999/", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -70,19 +54,6 @@ describe("browser target resolver", () => { ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("preserves credentialed loopback URLs for private IPv6 environments", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", - }).resolvedUrl, - ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); - }); - it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -94,38 +65,6 @@ describe("browser target resolver", () => { ).toBe("localhost:3000/app"); }); - it("keeps localhost navigation local for a local environment", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "localhost:3000/app", - resolvedUrl: "localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - - it("keeps localhost navigation local for the full IPv4 loopback range", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "url", - url: "http://localhost:3000/app", - }), - ).toEqual({ - requestedUrl: "http://localhost:3000/app", - resolvedUrl: "http://localhost:3000/app", - resolutionKind: "direct", - environmentId: "environment-1", - }); - }); - it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -189,63 +128,6 @@ describe("browser target resolver", () => { } }); - it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect( - resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), - ).toBe("http://localhost:5173/app?x=1#top"); - }); - - it("normalizes public URLs without treating them as environment ports", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( - "https://example.com/app", - ); - }); - - it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ - httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", - }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/app?mode=test", - }).resolvedUrl, - ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); - }); - - it("supports a local IPv6 environment host", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - }).resolvedUrl, - ).toBe("http://localhost:5173/"); - }); - - it("maps local IPv4 environment ports onto localhost for dual-stack guests", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); - const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); - expect( - resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { - kind: "environment-port", - port: 5173, - path: "/app", - }).resolvedUrl, - ).toBe("http://localhost:5173/app"); - }); - - it("leaves malformed input for the normal navigation error path", async () => { - const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); - expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" "); - }); - it("classifies exact private IPv4 and IPv6 boundaries", async () => { const { isPrivateNetworkHost } = await import("./browserTargetResolver"); const privateHosts = [ diff --git a/apps/web/src/browser/browserViewportActions.test.ts b/apps/web/src/browser/browserViewportActions.test.ts index a7633fd0963d..a441eecf08dd 100644 --- a/apps/web/src/browser/browserViewportActions.test.ts +++ b/apps/web/src/browser/browserViewportActions.test.ts @@ -1,39 +1,11 @@ -import type { PreviewViewportSetting } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { - BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, commitBrowserViewportChange, - runBrowserViewportMutation, subscribeBrowserViewportChange, } from "./browserViewportActions"; describe("browserViewportActions", () => { - it("routes drag commits to the visible tab handler and cleans up exactly that handler", async () => { - const first = vi.fn(async () => undefined); - const second = vi.fn(async () => undefined); - const unsubscribeFirst = subscribeBrowserViewportChange("tab-1", first); - const unsubscribeSecond = subscribeBrowserViewportChange("tab-1", second); - - unsubscribeFirst(); - await commitBrowserViewportChange("tab-1", { - _tag: "freeform", - width: 900, - height: 700, - }); - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledWith({ _tag: "freeform", width: 900, height: 700 }); - - unsubscribeSecond(); - await expect( - commitBrowserViewportChange("tab-1", { - _tag: "freeform", - width: 800, - height: 600, - }), - ).rejects.toThrow("No visible browser viewport handler"); - }); - it("commits viewport changes in order for each tab", async () => { let releaseFirst: (() => void) | undefined; let markFirstStarted: (() => void) | undefined; @@ -71,70 +43,4 @@ describe("browserViewportActions", () => { expect(calls).toEqual([800, 900]); unsubscribe(); }); - - it("serializes background mutations with visible viewport commits", async () => { - let releaseBackground: (() => void) | undefined; - const backgroundPending = new Promise((resolve) => { - releaseBackground = resolve; - }); - const calls: string[] = []; - const background = runBrowserViewportMutation("tab-shared", async () => { - calls.push("background"); - await backgroundPending; - }); - const unsubscribe = subscribeBrowserViewportChange("tab-shared", async () => { - calls.push("visible"); - }); - const visible = commitBrowserViewportChange("tab-shared", { - _tag: "freeform", - width: 900, - height: 700, - }); - - await vi.waitFor(() => expect(calls).toEqual(["background"])); - releaseBackground?.(); - await Promise.all([background, visible]); - - expect(calls).toEqual(["background", "visible"]); - unsubscribe(); - }); - - it("does not let a timed-out handler overtake a newer viewport commit", async () => { - vi.useFakeTimers(); - try { - let releaseFirst: (() => void) | undefined; - const delayed = new Promise((resolve) => { - releaseFirst = resolve; - }); - const handler = vi.fn(async (_setting: PreviewViewportSetting): Promise => undefined); - handler.mockImplementationOnce(() => delayed).mockResolvedValueOnce(undefined); - const unsubscribe = subscribeBrowserViewportChange("tab-timeout", handler); - const first = commitBrowserViewportChange("tab-timeout", { - _tag: "freeform", - width: 800, - height: 600, - }); - const firstResult = expect(first).rejects.toThrow( - "Timed out committing the browser viewport for tab tab-timeout", - ); - const second = commitBrowserViewportChange("tab-timeout", { - _tag: "freeform", - width: 900, - height: 700, - }); - - await vi.advanceTimersByTimeAsync(BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS); - await firstResult; - expect(handler).toHaveBeenCalledTimes(1); - - releaseFirst?.(); - await second; - - expect(handler).toHaveBeenCalledTimes(2); - expect(handler.mock.calls[1]?.[0]).toMatchObject({ width: 900, height: 700 }); - unsubscribe(); - } finally { - vi.useRealTimers(); - } - }); }); diff --git a/apps/web/src/browser/browserViewportLayout.test.ts b/apps/web/src/browser/browserViewportLayout.test.ts index ca2832bea6ce..1932a634fb6c 100644 --- a/apps/web/src/browser/browserViewportLayout.test.ts +++ b/apps/web/src/browser/browserViewportLayout.test.ts @@ -2,152 +2,16 @@ import { describe, expect, it } from "vite-plus/test"; import { resizeBrowserViewportFromRail, - resizeFreeformViewport, - resolveBrowserDeviceViewportLayout, resolveFittedBrowserViewport, - resolveBrowserViewportLayout, - resolveResponsiveBrowserViewportSize, } from "./browserViewportLayout"; describe("resolveBrowserViewportLayout", () => { - it("uses the current fixed viewport instead of stale fitted source dimensions", () => { - expect( - resolveFittedBrowserViewport( - { _tag: "freeform", width: 900, height: 600 }, - { width: 1280, height: 800, scale: 1 }, - ), - ).toEqual({ _tag: "freeform", width: 900, height: 600 }); - }); - it("preserves the last logical viewport when fitting a fill-mode surface", () => { expect( resolveFittedBrowserViewport({ _tag: "fill" }, { width: 320, height: 200, scale: 0.25 }), ).toEqual({ _tag: "freeform", width: 1280, height: 800 }); }); - it("fills the available surface in fill mode", () => { - expect(resolveBrowserViewportLayout({ width: 700, height: 500 }, { _tag: "fill" })).toEqual({ - canvasWidth: 700, - canvasHeight: 500, - viewportX: 0, - viewportY: 0, - viewportWidth: 700, - viewportHeight: 500, - viewportScale: 1, - fillsPanel: true, - }); - }); - - it("centers a smaller fixed viewport", () => { - expect( - resolveBrowserViewportLayout( - { width: 700, height: 1000 }, - { _tag: "freeform", width: 393, height: 852 }, - ), - ).toMatchObject({ - canvasWidth: 700, - canvasHeight: 1000, - viewportX: 154, - viewportY: 74, - viewportWidth: 393, - viewportHeight: 852, - }); - }); - - it("scales a larger fixed viewport down to fit without creating overflow", () => { - const layout = resolveBrowserViewportLayout( - { width: 600, height: 700 }, - { _tag: "freeform", width: 1440, height: 900 }, - ); - expect(layout).toMatchObject({ - canvasWidth: 600, - canvasHeight: 700, - viewportX: 0, - viewportY: 163, - viewportWidth: 600, - viewportHeight: 375, - }); - expect(layout.viewportScale).toBeCloseTo(5 / 12); - }); - - it("keeps fixed dimensions in page CSS pixels when browser zoom changes", () => { - expect( - resolveBrowserViewportLayout( - { width: 800, height: 700 }, - { _tag: "freeform", width: 400, height: 300 }, - 1.5, - ), - ).toMatchObject({ - viewportX: 100, - viewportY: 125, - viewportWidth: 600, - viewportHeight: 450, - }); - expect(resizeFreeformViewport({ width: 400, height: 300 }, { x: 150, y: 75 }, 1.5)).toEqual({ - width: 500, - height: 350, - }); - }); - - it("bounds freeform drag sizes and total render area", () => { - expect(resizeFreeformViewport({ width: 1024, height: 768 }, { x: -2000, y: -2000 })).toEqual({ - width: 240, - height: 240, - }); - const large = resizeFreeformViewport({ width: 1920, height: 1080 }, { x: 2000, y: 2000 }); - expect(large.width * large.height).toBeLessThanOrEqual(3840 * 2160); - }); - - it("resizes only the axes controlled by each edge", () => { - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: 500 }, 1, "west"), - ).toEqual({ width: 900, height: 600 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 500, y: 100 }, 1, "north"), - ).toEqual({ width: 800, height: 500 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: -100, y: -50 }, 1, "northwest"), - ).toEqual({ width: 900, height: 650 }); - }); - - it("preserves a locked aspect ratio from either axis", () => { - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 200, y: 0 }, 1, "east", 4 / 3), - ).toEqual({ width: 1000, height: 750 }); - expect( - resizeFreeformViewport({ width: 800, height: 600 }, { x: 0, y: 150 }, 1, "south", 4 / 3), - ).toEqual({ width: 1000, height: 750 }); - }); - - it("reserves persistent device-toolbar rails around the guest viewport", () => { - expect( - resolveBrowserDeviceViewportLayout( - { width: 1200, height: 900 }, - { _tag: "freeform", width: 1180, height: 852 }, - ), - ).toEqual({ - canvasWidth: 1200, - canvasHeight: 900, - viewportX: 10, - viewportY: 38, - viewportWidth: 1180, - viewportHeight: 852, - viewportScale: 1, - fillsPanel: false, - }); - }); - - it("captures the available framed area when responsive mode is enabled", () => { - expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({ - width: 1180, - height: 852, - }); - expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({ - width: 590, - height: 426, - }); - }); - it("keeps the grabbed rail under the pointer across centered layout boundaries", () => { const available = { width: 1120, height: 818 }; expect( diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 63e0fde1b3a7..d6bb4cae5932 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -22,7 +22,6 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; -import * as browserDefaults from "./browserDefaults"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; const DEFAULT_TAB_STATE = { @@ -46,59 +45,6 @@ describe("desktopTabLifetime", () => { vi.restoreAllMocks(); }); - it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { - vi.useFakeTimers(); - const failure = new Error("Settings read failed"); - vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); - const failed = acquireDesktopTab("tab_settings_retry"); - - await expect(failed.ready).rejects.toBe(failure); - expect(createTab).not.toHaveBeenCalled(); - failed.release(); - await vi.advanceTimersByTimeAsync(0); - - __setClientSettingsForTests({ - ...DEFAULT_CLIENT_SETTINGS, - browserDefaultZoomFactor: 1.25, - browserDefaultAppearance: "dark", - }); - createTab.mockResolvedValueOnce(undefined); - const retry = acquireDesktopTab("tab_settings_retry"); - await retry.ready; - - expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { - zoomFactor: 1.25, - colorScheme: "dark", - }); - retry.release(); - await vi.advanceTimersByTimeAsync(0); - }); - - it("shares tab creation readiness across concurrent leases", async () => { - let resolveCreation: (() => void) | undefined; - createTab.mockReturnValueOnce( - new Promise((resolve) => { - resolveCreation = resolve; - }), - ); - - const first = acquireDesktopTab("tab_readiness"); - const second = acquireDesktopTab("tab_readiness"); - - expect(first.ready).toBe(second.ready); - - let ready = false; - void first.ready.then(() => { - ready = true; - }); - await vi.waitFor(() => expect(createTab).toHaveBeenCalledOnce()); - expect(ready).toBe(false); - - resolveCreation?.(); - await first.ready; - expect(ready).toBe(true); - }); - it("keeps identical server tab ids from two environments in separate desktop slots", async () => { vi.useFakeTimers(); createTab.mockResolvedValue(undefined); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts deleted file mode 100644 index 831167095fa1..000000000000 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - HIDDEN_BROWSER_WEBVIEW_OFFSET, - resolveHostedBrowserWebviewWrapperStyle, -} from "./hostedBrowserWebviewStyle"; - -describe("resolveHostedBrowserWebviewWrapperStyle", () => { - it("places an active webview on its presented surface", () => { - expect( - resolveHostedBrowserWebviewWrapperStyle({ - active: true, - renderingActive: true, - rect: { x: 12, y: 34, width: 800, height: 600 }, - hiddenSize: { width: 1280, height: 800 }, - }), - ).toEqual({ - left: 12, - top: 34, - width: 800, - height: 600, - zIndex: 30, - pointerEvents: "auto", - }); - }); - - it("clips a floating webview to the mini-player frame", () => { - expect( - resolveHostedBrowserWebviewWrapperStyle({ - active: true, - renderingActive: true, - cornerRadius: 12, - zIndex: 48, - rect: { x: 12, y: 34, width: 360, height: 203 }, - hiddenSize: { width: 1280, height: 800 }, - }), - ).toMatchObject({ - left: 12, - top: 34, - width: 360, - height: 203, - borderRadius: 12, - zIndex: 48, - }); - }); - - it("suspends painting for an inactive webview", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: false, - rect: { x: 12, y: 34, width: 800, height: 600 }, - hiddenSize: { width: 393, height: 852 }, - }); - - expect(style).toEqual({ - left: HIDDEN_BROWSER_WEBVIEW_OFFSET, - top: HIDDEN_BROWSER_WEBVIEW_OFFSET, - width: 393, - height: 852, - zIndex: -1, - pointerEvents: "none", - visibility: "hidden", - }); - }); - - it("keeps an active background task paintable behind the app", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: true, - rect: null, - hiddenSize: { width: 1280, height: 800 }, - }); - - expect(style).toEqual({ - left: 0, - top: 0, - width: 1280, - height: 800, - zIndex: -1, - pointerEvents: "none", - visibility: "visible", - }); - }); - - it("keeps an inactive webview paintable without marking it as rendering-active", () => { - const style = resolveHostedBrowserWebviewWrapperStyle({ - active: false, - renderingActive: false, - keepPaintableWhenInactive: true, - rect: null, - hiddenSize: { width: 1280, height: 800 }, - }); - - expect(style).toEqual({ - left: HIDDEN_BROWSER_WEBVIEW_OFFSET, - top: HIDDEN_BROWSER_WEBVIEW_OFFSET, - width: 1280, - height: 800, - zIndex: -1, - pointerEvents: "none", - visibility: "visible", - }); - }); -}); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index 5bdf9b7c4f6d..420fdb9cc39a 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -16,7 +16,7 @@ export interface HostedBrowserWebviewWrapperStyle { readonly visibility?: "hidden" | "visible"; } -export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; +const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; diff --git a/apps/web/src/browser/previewRuntime.test.ts b/apps/web/src/browser/previewRuntime.test.ts deleted file mode 100644 index 951b52e90468..000000000000 --- a/apps/web/src/browser/previewRuntime.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - desktop: false, - primary: null as string | null, - serverBrowser: new Set(), -})); - -vi.mock("~/env", () => ({ isElectron: false })); -vi.mock("~/previewStateStore", () => ({ isPreviewSupportedInRuntime: () => state.desktop })); -vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => state.primary } })); -vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} })); -vi.mock("~/state/entities", () => ({ - readEnvironmentSupportsServerBrowser: (id: string) => state.serverBrowser.has(id), - useEnvironmentSupportsServerBrowser: () => false, -})); - -import { alternatePreviewRuntime, previewRuntimeFor } from "./previewRuntime"; - -const local = "local" as EnvironmentId; -const remote = "remote" as EnvironmentId; - -afterEach(() => { - state.desktop = false; - state.primary = null; - state.serverBrowser = new Set(); -}); - -describe("previewRuntimeFor", () => { - it("opens a remote environment's tabs on this computer in the desktop app", () => { - state.desktop = true; - state.primary = local; - state.serverBrowser = new Set([local, remote]); - - expect(previewRuntimeFor(remote)).toBeUndefined(); - expect(previewRuntimeFor(local)).toBe("server"); - }); - - it("uses the environment's browser where the client has none of its own", () => { - state.serverBrowser = new Set([remote]); - - expect(previewRuntimeFor(remote)).toBe("server"); - }); -}); - -describe("alternatePreviewRuntime", () => { - it("moves a remote environment's tab between this computer and the environment", () => { - state.desktop = true; - - expect(alternatePreviewRuntime(remote, local, true, {})).toBe("server"); - expect(alternatePreviewRuntime(remote, local, true, { runtime: "server" })).toBe("desktop"); - expect(alternatePreviewRuntime(local, local, true, {})).toBeNull(); - expect(alternatePreviewRuntime(remote, local, false, {})).toBeNull(); - }); - - it("offers no move outside the desktop app", () => { - expect(alternatePreviewRuntime(remote, null, true, { runtime: "server" })).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/previewRuntimeTabId.test.ts b/apps/web/src/browser/previewRuntimeTabId.test.ts index 7518ede3e5e7..9cb0114e1524 100644 --- a/apps/web/src/browser/previewRuntimeTabId.test.ts +++ b/apps/web/src/browser/previewRuntimeTabId.test.ts @@ -4,35 +4,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isCurrentPreviewRuntimeTab, previewRuntimeTabId } from "./previewRuntimeTabId"; describe("previewRuntimeTabId", () => { - it("scopes process-local tab ids to their environment, thread, and server epoch", () => { - const base = { - environmentId: EnvironmentId.make("environment-a"), - threadId: ThreadId.make("thread-a"), - }; - - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId( - { ...base, environmentId: EnvironmentId.make("environment-b") }, - "epoch-a", - "tab_1", - ), - ); - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId({ ...base, threadId: ThreadId.make("thread-b") }, "epoch-a", "tab_1"), - ); - expect(previewRuntimeTabId(base, "epoch-a", "tab_1")).not.toBe( - previewRuntimeTabId(base, "epoch-b", "tab_1"), - ); - }); - - it("is stable for the same runtime tab", () => { - const ref = { - environmentId: EnvironmentId.make("environment-a"), - threadId: ThreadId.make("thread-a"), - }; - expect(previewRuntimeTabId(ref, null, "tab_1")).toBe(previewRuntimeTabId(ref, null, "tab_1")); - }); - it("rejects a pinned operation target after the server epoch changes", () => { const ref = { environmentId: EnvironmentId.make("environment-a"), diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts deleted file mode 100644 index 75704cd2da2b..000000000000 --- a/apps/web/src/browser/previewWebviewConfigState.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -import { - loadPreviewWebviewConfig, - PreviewWebviewBridgeUnavailableError, - PreviewWebviewConfigLoadError, -} from "./previewWebviewConfigState"; - -const environmentId = EnvironmentId.make("environment-1"); - -describe("loadPreviewWebviewConfig", () => { - it.effect("reports a structurally distinct missing-bridge failure", () => - Effect.gen(function* () { - const error = yield* loadPreviewWebviewConfig(environmentId, undefined, null).pipe( - Effect.flip, - ); - - expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); - expect(error.environmentId).toBe(environmentId); - expect(error.message).toContain(environmentId); - expect("cause" in error).toBe(false); - }), - ); - - it.effect("preserves the bridge rejection as the load failure cause", () => - Effect.gen(function* () { - const cause = new Error("ipc unavailable"); - const error = yield* loadPreviewWebviewConfig(environmentId, undefined, { - getPreviewConfig: () => Promise.reject(cause), - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewWebviewConfigLoadError); - expect(error.environmentId).toBe(environmentId); - expect(error.cause).toBe(cause); - expect(error.message).not.toContain(cause.message); - }), - ); - - it.effect("forwards the environment id and profile to the bridge", () => - Effect.gen(function* () { - let requested: { environmentId: EnvironmentId; profileId: string | undefined } | null = null; - const config = { - partition: "persist:test-preview", - webPreferences: "sandbox=yes", - preloadUrl: null, - }; - const result = yield* loadPreviewWebviewConfig(environmentId, "work", { - getPreviewConfig: (requestedEnvironmentId, profileId) => { - requested = { environmentId: requestedEnvironmentId, profileId }; - return Promise.resolve(config); - }, - }); - - expect(requested).toEqual({ environmentId, profileId: "work" }); - expect(result).toEqual(config); - }), - ); -}); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 312dde29a66c..aa46b4c84527 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -43,7 +43,7 @@ export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type; type PreviewConfigBridge = Pick; -export const loadPreviewWebviewConfig = ( +const loadPreviewWebviewConfig = ( environmentId: EnvironmentId, profileId?: string, bridge: PreviewConfigBridge | null = previewBridge, diff --git a/apps/web/src/browser/recordingCompositor.test.ts b/apps/web/src/browser/recordingCompositor.test.ts deleted file mode 100644 index d10e69d24663..000000000000 --- a/apps/web/src/browser/recordingCompositor.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { DesktopPreviewRecordingInput } from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { createRecordingCompositor, RecordingDecorations } from "./recordingCompositor"; - -const primaryColor = "oklch(0.65 0.2 310)"; -vi.mock("./annotationTheme", () => ({ - readPreviewAnnotationTheme: () => ({ primary: "oklch(0.65 0.2 310)" }), -})); - -const options = { showKeyPresses: true, showMousePresses: true, frameRate: 30 }; -const pointer = ( - phase: "move" | "down" | "up" | "click", - x = 100, -): DesktopPreviewRecordingInput => ({ - type: "pointer", - phase, - x, - y: 80, - width: 800, - height: 600, -}); -const context = () => ({ - save: vi.fn(), - restore: vi.fn(), - beginPath: vi.fn(), - fill: vi.fn(), - stroke: vi.fn(), - ellipse: vi.fn(), - roundRect: vi.fn(), - fillText: vi.fn(), - drawImage: vi.fn(), - measureText: () => ({ width: 40 }), - globalAlpha: 1, - strokeStyle: "", - fillStyle: "", -}); - -describe("recording decorations", () => { - it("keeps rings aligned through dragging and stops following the cursor after release", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply(pointer("down"), 0); - decorations.apply(pointer("move", 120), 10); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 10); - expect(ctx.ellipse.mock.calls[0]?.slice(0, 4)).toEqual([240, 160, 40, 40]); - expect(ctx.strokeStyle).toBe(primaryColor); - expect(ctx.fillStyle).toBe(primaryColor); - expect(decorations.nextRedraw(10)).toBeNull(); - decorations.apply(pointer("up", 130), 20); - decorations.apply(pointer("move", 300), 30); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 320); - expect(ctx.ellipse.mock.calls[1]?.slice(0, 4)).toEqual([260, 160, 50, 50]); - ctx.ellipse.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 620); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(decorations.nextRedraw(620)).toBeNull(); - }); - - it("pulses agent clicks and clears decorations on blur or navigation", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply(pointer("click"), 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 300); - expect(ctx.ellipse).toHaveBeenCalledOnce(); - decorations.apply({ type: "clear" }, 301); - ctx.ellipse.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 302); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(decorations.nextRedraw(302)).toBeNull(); - }); - - it("holds shortcut badges until release, then expires them even on a static page", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - const key = { type: "key" as const, label: "⌘C", held: true, width: 800 }; - decorations.apply(key, 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5000); - expect(ctx.fillText.mock.calls[0]?.slice(0, 3)).toEqual(["⌘C", 800, 1098]); - decorations.apply({ ...key, held: false }, 5000); - expect(decorations.nextRedraw(5500)).toBe(400); - ctx.fillText.mockClear(); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 1600, 1200, 5900); - expect(ctx.fillText).not.toHaveBeenCalled(); - }); - - it("removes the previous key badge on password focus", () => { - const decorations = new RecordingDecorations(options, primaryColor); - const ctx = context(); - decorations.apply({ type: "key", label: "A", held: true, width: 800 }, 0); - decorations.apply({ type: "key", label: null, held: true, width: 800 }, 1); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 2); - expect(ctx.fillText).not.toHaveBeenCalled(); - }); - - it("honors independent opt-in flags", () => { - const decorations = new RecordingDecorations( - { ...options, showMousePresses: false }, - primaryColor, - ); - const ctx = context(); - decorations.apply(pointer("down"), 0); - decorations.apply({ type: "key", label: "⌘C", held: true, width: 800 }, 0); - decorations.draw(ctx as unknown as CanvasRenderingContext2D, 800, 600, 1); - expect(ctx.ellipse).not.toHaveBeenCalled(); - expect(ctx.fillText).toHaveBeenCalledOnce(); - }); -}); - -describe("detached recording compositor", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("keeps native capture when both decorations are off", async () => { - vi.stubGlobal("document", { - createElement: () => { - throw new Error("must not allocate"); - }, - }); - expect( - await createRecordingCompositor( - {} as MediaStream, - { - ...options, - showKeyPresses: false, - showMousePresses: false, - }, - () => { - throw new Error("must not subscribe"); - }, - ), - ).toBeNull(); - }); - - it.each([false, true])( - "releases the detached output on disposal or playback failure (%s)", - async (failPlayback) => { - const ctx = context(); - const stop = vi.fn(); - const unsubscribe = vi.fn(); - const cancelFrame = vi.fn(); - const source = { - getVideoTracks: () => [{ getSettings: () => ({ width: 800, height: 600 }) }], - } as unknown as MediaStream; - const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream; - const canvas = { width: 0, height: 0, getContext: () => ctx, captureStream: () => stream }; - const video = { - muted: false, - playsInline: false, - srcObject: null as MediaStream | null, - readyState: 2, - videoWidth: 800, - videoHeight: 600, - pause: vi.fn(), - play: async () => { - if (failPlayback) throw new Error("play failed"); - }, - requestVideoFrameCallback: () => 1, - cancelVideoFrameCallback: cancelFrame, - }; - vi.stubGlobal("document", { - createElement: (tag: string) => (tag === "canvas" ? canvas : video), - }); - vi.stubGlobal("window", { clearTimeout: vi.fn(), setTimeout: vi.fn() }); - const compositor = createRecordingCompositor(source, options, () => unsubscribe); - if (failPlayback) await expect(compositor).rejects.toThrow("play failed"); - else { - const result = await compositor; - expect(result?.stream).toBe(stream); - expect(ctx.drawImage).toHaveBeenCalledOnce(); - result?.dispose(); - result?.dispose(); - } - expect(stop).toHaveBeenCalledOnce(); - expect(unsubscribe).toHaveBeenCalledOnce(); - expect(cancelFrame).toHaveBeenCalledWith(1); - expect(video.srcObject).toBeNull(); - }, - ); -}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts index 8702b673a951..07e19c78d124 100644 --- a/apps/web/src/browser/recordingCompositor.ts +++ b/apps/web/src/browser/recordingCompositor.ts @@ -73,7 +73,7 @@ export async function createRecordingCompositor( } } -export class RecordingDecorations { +class RecordingDecorations { private ring: { x: number; y: number; diff --git a/apps/web/src/browser/serverPictureInPicture.test.ts b/apps/web/src/browser/serverPictureInPicture.test.ts deleted file mode 100644 index 629294a5f582..000000000000 --- a/apps/web/src/browser/serverPictureInPicture.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "~/previewStateStore"; -import { closeServerPictureInPicture, openServerPictureInPicture } from "./serverPictureInPicture"; - -vi.mock("~/state/previewStream", () => ({ - readPreviewStreamAccess: async () => ({}), -})); -vi.mock("@t3tools/client-runtime/preview/server-browser-stream", () => ({ - createPreviewFramePainter: () => ({ paint: vi.fn(), stop: vi.fn() }), - createPreviewStreamClient: () => ({ stop: vi.fn() }), -})); - -const ref = { - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), -}; -const stopTrack = vi.fn(); -const exitPictureInPicture = vi.fn(async () => {}); - -function navigate(url: string, tabId = "tab") { - applyPreviewServerSnapshot(ref, { - threadId: ref.threadId, - tabId, - navStatus: { _tag: "Success", url, title: "Document" }, - canGoBack: true, - canGoForward: false, - updatedAt: new Date().toISOString(), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - resetPreviewStateForTests(); - const video = { - style: { cssText: "" }, - readyState: 4, - play: async () => {}, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - remove: vi.fn(), - requestPictureInPicture: vi.fn(async () => {}), - }; - vi.stubGlobal("HTMLMediaElement", { HAVE_METADATA: 1 }); - vi.stubGlobal("document", { - pictureInPictureEnabled: true, - pictureInPictureElement: video, - exitPictureInPicture, - body: { append: vi.fn() }, - createElement: (tag: string) => - tag === "video" - ? video - : { - getContext: () => ({ fillRect: vi.fn() }), - captureStream: () => ({ getTracks: () => [{ stop: stopTrack }] }), - }, - }); - navigate("https://environment.test/api/assets/token/index.html"); -}); - -afterEach(() => { - closeServerPictureInPicture(); - resetPreviewStateForTests(); - vi.unstubAllGlobals(); -}); - -it("closes the video window and stream when its tab becomes a client-rendered PDF", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - navigate("https://environment.test/api/assets/token/guide.PDF#page=2"); - expect(exitPictureInPicture).toHaveBeenCalledOnce(); - expect(stopTrack).toHaveBeenCalledOnce(); -}); - -it("keeps PiP for ordinary navigation and PDFs in another tab", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - navigate("https://environment.test/api/assets/token/guide.pdf", "another-tab"); - navigate("https://example.com/paper.pdf"); - navigate("https://environment.test/api/assets/token/next.html"); - expect(exitPictureInPicture).not.toHaveBeenCalled(); - expect(stopTrack).not.toHaveBeenCalled(); -}); - -it("stops observing navigation when a PiP session closes", async () => { - await openServerPictureInPicture({ ...ref, tabId: "tab", seed: null }); - closeServerPictureInPicture(); - navigate("https://environment.test/api/assets/token/guide.pdf"); - expect(exitPictureInPicture).toHaveBeenCalledOnce(); - expect(stopTrack).toHaveBeenCalledOnce(); -}); diff --git a/apps/web/src/browser/useBrowserViewportResize.ts b/apps/web/src/browser/useBrowserViewportResize.ts index 924e3730bd98..b3f3091ba1f1 100644 --- a/apps/web/src/browser/useBrowserViewportResize.ts +++ b/apps/web/src/browser/useBrowserViewportResize.ts @@ -1,5 +1,3 @@ -"use client"; - import type { PreviewViewportSetting, PreviewViewportSize } from "@t3tools/contracts"; import { useEffect, diff --git a/apps/web/src/browser/webviewCrashRecovery.test.ts b/apps/web/src/browser/webviewCrashRecovery.test.ts deleted file mode 100644 index a13e6ff06f6b..000000000000 --- a/apps/web/src/browser/webviewCrashRecovery.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, - planWebviewCrashRecovery, - WEBVIEW_CRASH_RECOVERY_WINDOW_MS, -} from "./webviewCrashRecovery"; - -describe("planWebviewCrashRecovery", () => { - it("backs off and stops after a bounded number of rapid crashes", () => { - const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000); - expect(first).not.toBeNull(); - expect(first?.delayMs).toBe(250); - - const second = planWebviewCrashRecovery(first!.state, 1_100); - expect(second).not.toBeNull(); - expect(second?.delayMs).toBe(500); - - const third = planWebviewCrashRecovery(second!.state, 1_200); - expect(third).not.toBeNull(); - expect(third?.delayMs).toBe(1_000); - - expect(planWebviewCrashRecovery(third!.state, 1_300)).toBeNull(); - }); - - it("allows recovery again after the crash window expires", () => { - const first = planWebviewCrashRecovery(INITIAL_WEBVIEW_CRASH_RECOVERY_STATE, 1_000)!; - const second = planWebviewCrashRecovery(first.state, 1_100)!; - const third = planWebviewCrashRecovery(second.state, 1_200)!; - - expect(planWebviewCrashRecovery(third.state, 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS)).toEqual( - { - delayMs: 250, - state: { - attempts: 1, - windowStartedAt: 1_000 + WEBVIEW_CRASH_RECOVERY_WINDOW_MS, - }, - }, - ); - }); -}); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 606244d43643..9c11278ad4c8 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,4 +1,4 @@ -export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; +const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; diff --git a/apps/web/src/browserFaviconStore.test.ts b/apps/web/src/browserFaviconStore.test.ts index 1f2bc2bd7c0f..cfdedef00162 100644 --- a/apps/web/src/browserFaviconStore.test.ts +++ b/apps/web/src/browserFaviconStore.test.ts @@ -8,18 +8,12 @@ vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" import { flushPendingFaviconsForThread, lookupFavicon, - mergeBrowserFaviconState, recordFaviconForProject, recordFaviconForThread, - registerFaviconProjectForThread, resetBrowserFaviconsForTests, resolveBrowserFaviconStorage, useBrowserFaviconStore, } from "./browserFaviconStore"; -import { - BROWSER_FAVICON_MAX_ENTRIES, - migratePersistedBrowserFaviconState, -} from "./browserFaviconLogic"; const environmentId = EnvironmentId.make("env-1"); const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); @@ -38,22 +32,6 @@ describe("browser favicon store", () => { vi.restoreAllMocks(); }); - it("keeps the newest capture for an origin and permits an identical later revisit", () => { - const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon"); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null); - recordFaviconForProject( - projectRef, - favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="), - null, - ); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); - recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null); - expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([ - { dataUrl: PNG, capturedAt: 30 }, - ]); - expect(recordFavicon).toHaveBeenCalledTimes(2); - }); - it("does not share localhost icons across environments or physical projects", () => { const otherEnvironment = scopeProjectRef( EnvironmentId.make("env-2"), @@ -89,68 +67,6 @@ describe("browser favicon store", () => { ).toBeNull(); }); - it("finds a persisted IPv6 environment icon without a live connection", () => { - recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1"); - const migrated = migratePersistedBrowserFaviconState({ - byKey: useBrowserFaviconStore.getState().byKey, - }).byKey; - expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG); - }); - - it("keeps exact host aliases attached to the newest canonical icon", () => { - const olderPng = "data:image/png;base64,QkJCQg=="; - const newerPng = "data:image/png;base64,Q0NDQw=="; - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.2:3000/", 10, olderPng), - "192.168.64.2", - ); - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.3:3000/", 20, newerPng), - "192.168.64.3", - ); - recordFaviconForProject( - projectRef, - favicon("http://192.168.64.4:3000/", 15, olderPng), - "192.168.64.4", - ); - const byKey = useBrowserFaviconStore.getState().byKey; - for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) { - expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng); - } - expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20); - }); - - it("evicts an icon and its exact host aliases atomically", () => { - recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2"); - for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) { - recordFaviconForProject( - projectRef, - favicon(`https://example-${index}.com/`, 100 + index), - null, - ); - } - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - projectRef, - "http://192.168.64.2:3000/", - null, - ), - ).toBe(PNG); - - recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null); - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - projectRef, - "http://192.168.64.2:3000/", - null, - ), - ).toBeNull(); - }); - it("retains multiple origins until project and connection metadata hydrate", () => { expect( recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined), @@ -193,107 +109,6 @@ describe("browser favicon store", () => { expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined(); }); - it("keeps pending captures in store-owned state independent of bridge lifetime", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined); - const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey; - useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount }); - flushPendingFaviconsForThread(threadRef, projectRef, "localhost"); - expect(useBrowserFaviconStore.getState().byKey).toEqual({ - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 }, - }); - }); - - it("flushes and resolves a pending draft-thread favicon after physical project registration", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined); - registerFaviconProjectForThread(threadRef, projectRef); - const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"]; - expect(registered).toEqual(projectRef); - expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true); - expect( - lookupFavicon( - useBrowserFaviconStore.getState().byKey, - registered!, - "http://localhost:8025/", - null, - ), - ).toBe(PNG); - }); - - it("bounds pending memory by origin and thread", () => { - for (let thread = 0; thread < 22; thread += 1) { - for (let port = 3000; port < 3012; port += 1) { - recordFaviconForThread( - { environmentId, threadId: ThreadId.make(`thread-${thread}`) }, - favicon(`http://localhost:${port}/`, port), - null, - undefined, - ); - } - } - const pending = useBrowserFaviconStore.getState().pendingByThreadKey; - expect(Object.keys(pending)).toHaveLength(20); - expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe( - true, - ); - }); - - it("sanitizes hydrated state while preserving actions and transient pending data", () => { - recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined); - const current = useBrowserFaviconStore.getState(); - const merged = mergeBrowserFaviconState( - { - byKey: { - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, - "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 }, - "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 }, - ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 }, - }, - }, - current, - ); - expect(merged.byKey).toEqual({ - "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 }, - }); - expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey); - expect(typeof merged.recordFavicon).toBe("function"); - }); - - it("falls back to memory when localStorage access throws", () => { - vi.stubGlobal( - "window", - Object.defineProperty({}, "localStorage", { - get: () => { - throw new Error("storage blocked"); - }, - }), - ); - const storage = resolveBrowserFaviconStorage(); - storage.setItem("key", "value"); - expect(storage.getItem("key")).toBe("value"); - }); - - it("falls back to memory when localStorage operations throw", () => { - vi.stubGlobal("window", { - localStorage: { - getItem: vi.fn(() => { - throw new Error("read blocked"); - }), - setItem: vi.fn(() => { - throw new Error("quota exceeded"); - }), - removeItem: vi.fn(() => { - throw new Error("remove blocked"); - }), - }, - }); - const storage = resolveBrowserFaviconStorage(); - - storage.setItem("key", "value"); - expect(storage.getItem("key")).toBe("value"); - storage.removeItem("key"); - expect(storage.getItem("key")).toBeNull(); - }); - it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => { vi.stubGlobal("window", { localStorage: { diff --git a/apps/web/src/browserFaviconStore.ts b/apps/web/src/browserFaviconStore.ts index 4051b4ebfbfb..c8d6fd521d24 100644 --- a/apps/web/src/browserFaviconStore.ts +++ b/apps/web/src/browserFaviconStore.ts @@ -162,7 +162,7 @@ export const useBrowserFaviconStore = create()( ), ); -export function mergeBrowserFaviconState( +function mergeBrowserFaviconState( persistedState: unknown, currentState: BrowserFaviconStoreState, ): BrowserFaviconStoreState { diff --git a/apps/web/src/browserHistoryStore.test.ts b/apps/web/src/browserHistoryStore.test.ts index cdabd8e80177..e06b35b3ccdb 100644 --- a/apps/web/src/browserHistoryStore.test.ts +++ b/apps/web/src/browserHistoryStore.test.ts @@ -9,16 +9,12 @@ const { readPreparedConnection } = vi.hoisted(() => ({ vi.mock("~/state/session", () => ({ readPreparedConnection })); import { - BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT, BROWSER_HISTORY_MAX_PROJECTS, - BROWSER_HISTORY_MAX_TITLE_LENGTH, type BrowserHistoryEntry, - evictExcessProjects, mergeBrowserHistoryState, migratePersistedBrowserHistoryState, normalizeHistoryUrl, recordVisitForThread, - removeUrlForThread, resetBrowserHistoryForTests, setTitleForThreadUrl, upsertHistoryEntry, @@ -32,12 +28,6 @@ function entry(overrides: Partial = {}): BrowserHistoryEntr beforeEach(() => readPreparedConnection.mockReturnValue(null)); afterEach(() => vi.restoreAllMocks()); -function spyOnPersistWrites() { - const storage = useBrowserHistoryStore.persist.getOptions().storage; - if (!storage) throw new Error("Browser history persistence storage is unavailable."); - return vi.spyOn(storage, "setItem"); -} - describe("normalizeHistoryUrl", () => { it("normalizes bare loopback hosts to http and keeps path/query", () => { expect(normalizeHistoryUrl("localhost:3000/admin?tab=1")).toBe( @@ -45,10 +35,6 @@ describe("normalizeHistoryUrl", () => { ); }); - it("normalizes bare public hosts to https", () => { - expect(normalizeHistoryUrl("myapp.test")).toBe("https://myapp.test/"); - }); - it("preserves hash routes and strips credentials", () => { expect(normalizeHistoryUrl("http://localhost:3000/app#/route")).toBe( "http://localhost:3000/app#/route", @@ -64,43 +50,6 @@ describe("normalizeHistoryUrl", () => { }); describe("upsertHistoryEntry", () => { - it("prepends new urls", () => { - const next = upsertHistoryEntry([entry()], "http://localhost:5173/", 2000); - expect(next.map((e) => e.url)).toEqual(["http://localhost:5173/", "http://localhost:3000/"]); - expect(next[0]).toEqual({ url: "http://localhost:5173/", lastVisitedAt: 2000 }); - }); - - it("moves revisits to front, updates the timestamp, and keeps the title", () => { - const existing = [ - entry({ url: "http://a.test/", lastVisitedAt: 500, title: "A" }), - entry({ url: "http://b.test/", lastVisitedAt: 400 }), - ]; - const next = upsertHistoryEntry(existing, "http://b.test/", 3000); - expect(next.map((e) => e.url)).toEqual(["http://b.test/", "http://a.test/"]); - expect(next[0]?.lastVisitedAt).toBe(3000); - expect(next[1]?.title).toBe("A"); - }); - - it("caps the list at the per-project limit", () => { - const full = Array.from({ length: BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT }, (_, i) => - entry({ url: `http://localhost:${3000 + i}/`, lastVisitedAt: i }), - ); - const next = upsertHistoryEntry(full, "http://new.test/", 9999); - expect(next).toHaveLength(BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT); - expect(next[0]?.url).toBe("http://new.test/"); - const lastPort = 3000 + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT - 1; - expect(next.some((e) => e.url === `http://localhost:${lastPort}/`)).toBe(false); - expect(next.some((e) => e.url === "http://localhost:3000/")).toBe(true); - }); - - it("with insertOrdered, slots an older entry below a newer one instead of prepending", () => { - const existing = [entry({ url: "http://newer.test/", lastVisitedAt: 2000 })]; - const next = upsertHistoryEntry(existing, "http://older.test/", 1000, { - insertOrdered: true, - }); - expect(next.map((e) => e.url)).toEqual(["http://newer.test/", "http://older.test/"]); - }); - it("with insertOrdered, replaying an older visit for an existing entry keeps its newer timestamp", () => { const existing = [entry({ url: "http://a.test/", lastVisitedAt: 2000 })]; const next = upsertHistoryEntry(existing, "http://a.test/", 1000, { insertOrdered: true }); @@ -108,22 +57,6 @@ describe("upsertHistoryEntry", () => { }); }); -describe("evictExcessProjects", () => { - it("keeps the most recently visited projects when over the cap", () => { - const byProjectKey = Object.fromEntries( - Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 2 }, (_, i) => [ - `project-${i}`, - [entry({ lastVisitedAt: i })], - ]), - ); - const next = evictExcessProjects(byProjectKey); - expect(Object.keys(next)).toHaveLength(BROWSER_HISTORY_MAX_PROJECTS); - expect(next["project-0"]).toBeUndefined(); - expect(next["project-1"]).toBeUndefined(); - expect(next[`project-${BROWSER_HISTORY_MAX_PROJECTS + 1}`]).toBeDefined(); - }); -}); - describe("migratePersistedBrowserHistoryState", () => { it("drops malformed state and invalid entries", () => { expect(migratePersistedBrowserHistoryState(null)).toEqual({ byProjectKey: {} }); @@ -146,17 +79,6 @@ describe("migratePersistedBrowserHistoryState", () => { expect(migrated.byProjectKey["bad"]).toBeUndefined(); }); - it("normalizes persisted urls with the same rules as live writes", () => { - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [{ url: "a.test/path#section", lastVisitedAt: 100 }], - }, - }); - expect(migrated.byProjectKey["good"]).toEqual([ - { url: "https://a.test/path#section", lastVisitedAt: 100 }, - ]); - }); - it("restores MRU ordering, deduplicates normalized urls, and enforces project bounds", () => { const byProjectKey = Object.fromEntries( Array.from({ length: BROWSER_HISTORY_MAX_PROJECTS + 1 }, (_, index) => [ @@ -179,33 +101,6 @@ describe("migratePersistedBrowserHistoryState", () => { { url: "https://a.test/", lastVisitedAt: 2 }, ]); }); - - it("rejects a lastVisitedAt outside Date's valid range", () => { - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [ - { url: "http://a.test/", lastVisitedAt: 100 }, - { url: "http://b.test/", lastVisitedAt: 1e20 }, - ], - }, - }); - expect(migrated.byProjectKey["good"]).toEqual([{ url: "http://a.test/", lastVisitedAt: 100 }]); - }); - - it("truncates oversized persisted titles to the contract bound", () => { - const oversized = "x".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 100); - const migrated = migratePersistedBrowserHistoryState({ - byProjectKey: { - good: [{ url: "http://a.test/", lastVisitedAt: 100, title: oversized }], - }, - }); - expect(migrated.byProjectKey["good"]?.[0]?.title).toHaveLength( - BROWSER_HISTORY_MAX_TITLE_LENGTH, - ); - expect(migrated.byProjectKey["good"]?.[0]?.title).toBe( - oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH), - ); - }); }); const threadRef = { @@ -218,30 +113,6 @@ describe("useBrowserHistoryStore", () => { resetBrowserHistoryForTests(); }); - it("records visits for registered threads under the project key", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "myapp.test/admin#section", 1234); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]).toEqual([ - { url: "https://myapp.test/admin#section", lastVisitedAt: 1234 }, - ]); - }); - - it("does not persist when a thread is already registered to the same project", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - const persist = spyOnPersistWrites(); - - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - - expect(persist).not.toHaveBeenCalled(); - }); - - it("ignores invalid urls whether queued pending or recorded post-registration", () => { - recordVisitForThread(threadRef, "ftp://a.test/", 1); - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "ftp://a.test/", 2); - expect(useBrowserHistoryStore.getState().byProjectKey).toEqual({}); - }); - it("sets titles update-only via the thread helper", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); setTitleForThreadUrl(threadRef, "http://a.test/", "Should not create"); @@ -251,19 +122,6 @@ describe("useBrowserHistoryStore", () => { expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("My App"); }); - it("does not persist when the title is already set", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); - const persist = spyOnPersistWrites(); - const byProjectKey = useBrowserHistoryStore.getState().byProjectKey; - - setTitleForThreadUrl(threadRef, "http://a.test/", "My App"); - - expect(useBrowserHistoryStore.getState().byProjectKey).toBe(byProjectKey); - expect(persist).not.toHaveBeenCalled(); - }); - it("sets a title against a settled url that differs from the stored one only by a trailing slash", () => { useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); recordVisitForThread(threadRef, "http://a.test/community", 1); @@ -306,44 +164,6 @@ describe("useBrowserHistoryStore", () => { { url: "http://localhost:5173/app", lastVisitedAt: 5 }, ]); }); - - it("does not match a genuinely different path via the trailing-slash comparison", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/community", 1); - setTitleForThreadUrl(threadRef, "http://a.test/community/foo", "Foo"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBeUndefined(); - }); - - it("updates only the most recent entry when several share a trailing-slash comparison key", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/community/", 1); - recordVisitForThread(threadRef, "http://a.test/community", 2); - setTitleForThreadUrl(threadRef, "http://a.test/community/", "Community"); - const entries = useBrowserHistoryStore.getState().byProjectKey["proj-a"]; - expect(entries?.[0]).toMatchObject({ url: "http://a.test/community", title: "Community" }); - expect(entries?.[1]).toMatchObject({ url: "http://a.test/community/" }); - expect(entries?.[1]?.title).toBeUndefined(); - }); - - it("truncates oversized titles to the contract bound", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - const oversized = "y".repeat(BROWSER_HISTORY_MAX_TITLE_LENGTH + 50); - setTitleForThreadUrl(threadRef, "http://a.test/", oversized); - const title = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title; - expect(title).toHaveLength(BROWSER_HISTORY_MAX_TITLE_LENGTH); - expect(title).toBe(oversized.slice(0, BROWSER_HISTORY_MAX_TITLE_LENGTH)); - }); - - it("removes entries", () => { - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - recordVisitForThread(threadRef, "http://a.test/", 1); - recordVisitForThread(threadRef, "http://b.test/", 2); - removeUrlForThread(threadRef, "http://a.test/"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url)).toEqual([ - "http://b.test/", - ]); - }); }); describe("pendingVisitsByThreadKey", () => { @@ -365,18 +185,6 @@ describe("pendingVisitsByThreadKey", () => { expect(useBrowserHistoryStore.getState().pendingVisitsByThreadKey).toEqual({}); }); - it("caps the per-thread pending list at 10, dropping the oldest", () => { - for (let i = 0; i < 12; i++) { - recordVisitForThread(threadRef, `http://a.test/${i}`, i); - } - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - const urls = useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.map((e) => e.url); - expect(urls).toHaveLength(10); - expect(urls).not.toContain("http://a.test/0"); - expect(urls).not.toContain("http://a.test/1"); - expect(urls?.[0]).toBe("http://a.test/11"); - }); - it("slots a replayed visit by timestamp instead of hoisting it above a newer live visit", () => { const otherThreadRef = { environmentId: EnvironmentId.make("env-1"), @@ -409,13 +217,6 @@ describe("pendingTitlesByThreadKey", () => { expect(entries?.[0]).toMatchObject({ url: "http://a.test/", title: "My App" }); expect(useBrowserHistoryStore.getState().pendingTitlesByThreadKey).toEqual({}); }); - - it("preserves environment host matching while a title is pending", () => { - recordVisitForThread(threadRef, "http://localhost:5173/app", 1); - setTitleForThreadUrl(threadRef, "http://192.168.64.2:5173/app", "Local App", "192.168.64.2"); - useBrowserHistoryStore.getState().registerThreadProject(threadRef, "proj-a"); - expect(useBrowserHistoryStore.getState().byProjectKey["proj-a"]?.[0]?.title).toBe("Local App"); - }); }); describe("mergeBrowserHistoryState", () => { diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index aaba58c8a7e8..770b26e0a686 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. import * as NodeChildProcess from "node:child_process"; -import * as NodeEvents from "node:events"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -8,12 +7,9 @@ import * as NodeURL from "node:url"; import * as NodeUtil from "node:util"; import react from "@vitejs/plugin-react"; -import { createLogger, createServer } from "vite-plus"; +import { createServer } from "vite-plus"; import { expect, it } from "vite-plus/test"; -import { tailwindPlugins } from "../vite/tailwind"; -import { warmLazyEntries } from "../vite/warmBundledDev"; - const execFile = NodeUtil.promisify(NodeChildProcess.execFile); it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { @@ -122,143 +118,3 @@ console.log("App started with React refresh ready.");`, await NodeFSP.rm(root, { recursive: true, force: true }); } }); - -it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); - const events = new NodeEvents.EventEmitter(); - let server: Awaited> | undefined; - let socket: WebSocket | undefined; - let css = ""; - - try { - await NodeFSP.writeFile( - NodePath.join(root, "index.html"), - '', - ); - const source = - 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; - await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); - await NodeFSP.writeFile( - NodePath.join(root, "style.css"), - '@import "tailwindcss" source(none); @source "./main.ts";', - ); - const logger = createLogger("silent"); - logger.error = (message) => events.emit("error", new Error(message)); - const connected = NodeEvents.EventEmitter.once(events, "connected"); - const ready = NodeEvents.EventEmitter.once(events, "ready"); - server = await createServer({ - configFile: false, - root, - customLogger: logger, - resolve: { - alias: { - tailwindcss: NodeURL.fileURLToPath( - new URL("../node_modules/tailwindcss/index.css", import.meta.url), - ), - }, - }, - experimental: { bundledDev: true }, - plugins: [ - ...tailwindPlugins(true), - { - name: "observe-tailwind-output", - enforce: "pre", - transform(code, id) { - if (id.endsWith("/style.css")) css = code; - }, - async generateBundle() { - // Keep the build pending until the socket can receive Vite's ready message. - await connected; - }, - }, - ], - server: { host: "127.0.0.1", port: 0 }, - }); - await server.listen(); - const address = server.httpServer?.address(); - if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); - - server.ws.on("vite:client-connected", () => events.emit("connected")); - socket = new WebSocket( - `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, - "vite-hmr", - ); - socket.addEventListener("open", () => { - socket?.send( - JSON.stringify({ - type: "custom", - event: "vite:client-connected", - data: { clientId: "tailwind-test" }, - }), - ); - }); - socket.addEventListener("message", ({ data }) => { - const message: unknown = JSON.parse(String(data)); - if (message !== null && typeof message === "object" && "type" in message) { - // generateBundle runs before Vite stores the files for HTTP requests. - if ( - message.type === "full-reload" && - "ifFallback" in message && - message.ifFallback === true - ) { - events.emit("ready"); - } else if (message.type === "bundled-dev-update") { - events.emit("updated"); - } - } - }); - await connected; - await ready; - const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); - expect(entry.headers.get("content-type")).toContain("javascript"); - await entry.text(); - - const updated = NodeEvents.EventEmitter.once(events, "updated"); - await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); - await updated; - expect(css).toContain("margin: 137px"); - } finally { - socket?.close(); - await server?.close(); - await NodeFSP.rm(root, { recursive: true, force: true }); - } -}); - -it("warms the wanted lazy chunk chain and nothing else", async () => { - const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-warm-")); - let server: Awaited> | undefined; - - try { - await NodeFSP.mkdir(NodePath.join(root, "src")); - await NodeFSP.writeFile( - NodePath.join(root, "index.html"), - '', - ); - await NodeFSP.writeFile(NodePath.join(root, "src/bootstrap.ts"), 'void import("./main");'); - await NodeFSP.writeFile( - NodePath.join(root, "src/main.ts"), - 'export const route = () => import("./route"); export const rare = () => import("./rare");', - ); - await NodeFSP.writeFile(NodePath.join(root, "src/route.ts"), "export const route = 1;"); - await NodeFSP.writeFile(NodePath.join(root, "src/rare.ts"), "export const rare = 1;"); - - server = await createServer({ - configFile: false, - root, - logLevel: "silent", - experimental: { bundledDev: true }, - server: { host: "127.0.0.1", port: 0 }, - }); - await server.listen(); - - // The route is only discoverable after main compiles, so this also covers the pass walk. - const warmed = await warmLazyEntries(server, ["src/main.ts", "src/route.ts"]); - expect(warmed.map((id) => NodePath.relative(root, id.split("?")[0] ?? id))).toEqual([ - "src/main.ts", - "src/route.ts", - ]); - } finally { - await server?.close(); - await NodeFSP.rm(root, { recursive: true, force: true }); - } -}); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 5154fe47e035..fd3821b81b53 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -38,20 +38,6 @@ afterEach(() => { }); describe("clientPersistenceStorage", () => { - it("persists client settings in browser storage", async () => { - getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - const settings = { - ...DEFAULT_CLIENT_SETTINGS, - timestampFormat: "24-hour" as const, - }; - - writeBrowserClientSettings(settings); - - expect(readBrowserClientSettings()).toEqual(settings); - }); - it.each(["not-json", '{"wordWrap":"invalid"}'])( "does not treat invalid saved settings as absent: %s", async (value) => { @@ -92,53 +78,4 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); expect(write).not.toHaveBeenCalled(); }); - - it("defaults word wrap on and discards obsolete wrapping preferences", async () => { - const testWindow = getTestWindow(); - testWindow.localStorage.setItem( - "t3code:client-settings:v1", - JSON.stringify({ - chatWordWrap: false, - diffWordWrap: false, - }), - ); - const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - const settings = readBrowserClientSettings(); - - expect(settings).toEqual( - expect.objectContaining({ - wordWrap: true, - }), - ); - expect(settings).not.toHaveProperty("chatWordWrap"); - expect(settings).not.toHaveProperty("diffWordWrap"); - }); - - it("keeps the default diff file state across reloads and defaults it to collapsed", async () => { - const testWindow = getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - - testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); - }); - - it("keeps the diff layout across reloads and defaults it to stacked", async () => { - const testWindow = getTestWindow(); - const { readBrowserClientSettings, writeBrowserClientSettings } = - await import("./clientPersistenceStorage"); - - expect(readBrowserClientSettings()).toBeNull(); - testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); - expect(readBrowserClientSettings()?.diffLayout).toBe("stacked"); - - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" }); - expect(readBrowserClientSettings()?.diffLayout).toBe("split"); - }); }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 3126228bd102..024089f0e0dd 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -29,22 +29,6 @@ const snapshot: PreviewSessionSnapshot = { }; describe("closedViewStore", () => { - it.each([null, {}, { entries: null }, { entries: {} }, { entries: "invalid" }])( - "rehydrates malformed older history %j as empty history", - async (state) => { - useClosedViewStore.getState().remember(diff(refA)); - const { storage, name } = useClosedViewStore.persist.getOptions(); - await storage!.setItem(name!, { - state: state as { entries: ClosedViewEntry[] }, - version: 0, - }); - await useClosedViewStore.persist.rehydrate(); - expect(useClosedViewStore.persist.hasHydrated()).toBe(true); - expect(useClosedViewStore.getState().entries).toEqual([]); - expect(await storage!.getItem(name!)).toEqual({ state: { entries: [] }, version: 2 }); - }, - ); - it.each([ { id: "bad", kind: "browser", threadRef: refA, snapshot: {} }, { @@ -191,32 +175,4 @@ describe("closedViewStore", () => { expect(saved).not.toContain("private.example"); expect(saved).not.toContain("Private page"); }); - - it("keeps newest first, moves a re-closed tab to the front, and caps at 20", () => { - const store = useClosedViewStore.getState(); - store.remember(diff(refA)); - store.remember(diff(refB)); - store.remember(diff(refA)); - expect(useClosedViewStore.getState().entries).toMatchObject([ - { threadRef: refA }, - { threadRef: refB }, - ]); - - for (let index = 0; index < 24; index++) { - store.remember({ - kind: "panel-tab", - threadRef: refA, - surface: { - kind: "file", - id: `file:${index}`, - relativePath: `${index}.ts`, - revealLine: null, - revealRequestId: 0, - }, - }); - } - const entries = useClosedViewStore.getState().entries; - expect(entries).toHaveLength(20); - expect(entries[0]).toMatchObject({ surface: { id: "file:23" } }); - }); }); diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 63bee2d9ccfa..97db1089c8e8 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - buildConnectCliClerkAuthorizeUrl, - connectCliSignInRedirectUrl, - hasConnectCliAuthConfig, -} from "./connectCliAuth"; +import { buildConnectCliClerkAuthorizeUrl, connectCliSignInRedirectUrl } from "./connectCliAuth"; const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`; @@ -13,16 +9,6 @@ describe("connectCliAuth", () => { vi.unstubAllEnvs(); }); - it("requires both the publishable key and the CLI OAuth client id", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com"); - expect(hasConnectCliAuthConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); - expect(hasConnectCliAuthConfig()).toBe(true); - }); - it("builds a PKCE authorize URL that redirects to the CLI's loopback listener", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); @@ -43,17 +29,6 @@ describe("connectCliAuth", () => { expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); - it("returns null when the CLI OAuth client id is not configured", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - expect( - buildConnectCliClerkAuthorizeUrl({ - state: "state-1", - challenge: "challenge-1", - loopbackPort: 34338, - }), - ).toBeNull(); - }); - it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); @@ -68,17 +43,4 @@ describe("connectCliAuth", () => { expect(redirectUrl).not.toBe(connectUrl); expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); }); - - it("falls back to the current URL when the authorize URL cannot be built", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); - - const connectUrl = - "https://app.t3.codes/connect#state=state-1&challenge=challenge-1&port=34338"; - expect( - connectCliSignInRedirectUrl( - { state: "state-1", challenge: "challenge-1", loopbackPort: 34338 }, - connectUrl, - ), - ).toBe(connectUrl); - }); }); diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 5510b698216f..25a5a21400a3 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -13,7 +13,7 @@ function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } -export function hasConnectCliAuthConfig(): boolean { +function hasConnectCliAuthConfig(): boolean { return Boolean( resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(), ); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index dbcaacfa25a4..65cf9402e5fc 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -25,13 +25,7 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; -import { - linkPrimaryEnvironmentToCloud, - readPrimaryCloudLinkState, - type CloudLinkTarget, - unlinkPrimaryEnvironmentFromCloud, - updatePrimaryCloudPreferences, -} from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { environmentId: "environment-1", @@ -136,10 +130,6 @@ function withServices( return effect.pipe(Effect.provide(layerServices(options))); } -function bodyText(body: BodyInit | null | undefined): string { - return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); -} - beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); @@ -154,38 +144,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it.effect("reads primary cloud link state from the explicit target", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: false, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - - expect(Option.fromNullishOr(state)).toEqual( - Option.some({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: false, - }), - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-state", - ); - }), - ); - it.effect("uses desktop bearer auth for primary cloud link state", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( @@ -213,181 +171,4 @@ describe("web cloud link environment client", () => { expect(request.headers.get("authorization")).toBe("Bearer desktop-bearer-token"); }), ); - - it.effect("updates agent activity publishing for the explicit primary target", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - linked: true, - cloudUserId: "user-1", - relayUrl: "https://relay.example.test", - relayIssuer: "https://relay.example.test", - managedTunnelActive: true, - publishAgentActivity: true, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const state = yield* withServices( - updatePrimaryCloudPreferences({ - target: TARGET, - publishAgentActivity: true, - }), - ); - - expect(state.publishAgentActivity).toBe(true); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/preferences", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ - publishAgentActivity: true, - }); - }), - ); - - it.effect("links an available primary environment without invoking installation", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - challenge: "challenge", - expiresAt: "2026-06-06T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce(Response.json("signed-proof")) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: TARGET.environmentId, - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://relay.example.test", - cloudUserId: "user-1", - environmentCredential: "environment-credential", - cloudMintPublicKey: "public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - ); - - expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-proof", - ); - expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - challenge: "challenge", - endpoint: { - httpBaseUrl: TARGET.httpBaseUrl, - wsBaseUrl: TARGET.wsBaseUrl, - }, - }); - }), - ); - - it.effect("links publish-only without a managed tunnel", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - challenge: "challenge", - expiresAt: "2026-06-06T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce(Response.json("signed-proof")) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: TARGET.environmentId, - endpoint: { - httpBaseUrl: TARGET.httpBaseUrl, - wsBaseUrl: TARGET.wsBaseUrl, - providerKind: "manual", - }, - endpointRuntime: null, - relayIssuer: "https://relay.example.test", - cloudUserId: "user-1", - environmentCredential: "environment-credential", - cloudMintPublicKey: "public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - mode: "publish_only", - }), - ); - - expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ - managedTunnelsEnabled: false, - }); - expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - endpoint: { providerKind: "manual" }, - }); - }), - ); - - it.effect("installs a missing relay client before linking", () => - Effect.gen(function* () { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); - - yield* withServices( - linkPrimaryEnvironmentToCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - { - status: { status: "available", version: "2026.6.0" }, - installEvents: [], - }, - ).pipe(Effect.flip); - - expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); - }), - ); - - it.effect("unlinks locally before revoking the relay record", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ ok: true })); - vi.stubGlobal("fetch", fetchMock); - - yield* withServices( - unlinkPrimaryEnvironmentFromCloud({ - target: TARGET, - clerkToken: "clerk-token", - }), - ); - - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(String(fetchMock.mock.calls[1]?.[0])).toContain( - `/v1/client/environment-links/${TARGET.environmentId}`, - ); - }), - ); }); diff --git a/apps/web/src/cloud/managedAuth.test.ts b/apps/web/src/cloud/managedAuth.test.ts index 63001bdb184d..27f8a2187901 100644 --- a/apps/web/src/cloud/managedAuth.test.ts +++ b/apps/web/src/cloud/managedAuth.test.ts @@ -1,4 +1,4 @@ -import { managedRelaySessionAtom, setManagedRelaySession } from "@t3tools/client-runtime/relay"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import * as Effect from "effect/Effect"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,15 +44,4 @@ describe("managed relay authentication", () => { expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull(); await cleanup; }); - - it("replaces an existing account session atomically", () => { - setManagedRelaySession(appAtomRegistry, { - accountId: "account-1", - readClerkToken: async () => "account-1-token", - }); - - activateManagedRelayAuthentication("account-2", async () => "account-2-token"); - - expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-2"); - }); }); diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index bab63e8260c1..182367a410de 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -1,10 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - CloudPublicConfigMissingError, - hasCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "./publicConfig.ts"; +import { hasCloudPublicConfig } from "./publicConfig.ts"; afterEach(() => { vi.unstubAllEnvs(); @@ -12,22 +8,6 @@ afterEach(() => { }); describe("hasCloudPublicConfig", () => { - it("requires both public cloud values", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", ""); - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); - vi.stubEnv("VITE_T3CODE_RELAY_URL", ""); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); - expect(hasCloudPublicConfig()).toBe(false); - - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); - expect(hasCloudPublicConfig()).toBe(true); - }); - it("rejects an insecure relay URL", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); @@ -89,32 +69,10 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(expected); }); - it("keeps development keys available on private browser origins", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", `pk_test_${btoa("tenant.clerk.accounts.dev$")}`); - vi.stubGlobal("window", { location: new URL("http://localhost:3773") }); - - expect(hasCloudPublicConfig()).toBe(true); - }); - it("keeps production keys available in the Electron auth shell", () => { vi.stubGlobal("window", { location: new URL("file:///app/index.html"), desktopBridge: {} }); expect(hasCloudPublicConfig()).toBe(true); }); - - it("disables cloud auth for an invalid production key without throwing", () => { - vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_live_!"); - vi.stubGlobal("window", { location: new URL("https://app.example.com") }); - - expect(hasCloudPublicConfig()).toBe(false); - }); - }); - - it("reports the missing Clerk JWT template as structured configuration", () => { - vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); - - expect(() => resolveRelayClerkTokenOptions()).toThrowError( - new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), - ); }); }); diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 8ec1820d1115..2f505df24fb5 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { - completeRelayClientInstallDialogClose, - finishRelayClientInstall, - readRelayClientInstallDialogState, RelayClientInstallConfirmationConflictError, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, @@ -16,59 +13,6 @@ describe("relay client install dialog coordinator", () => { resetRelayClientInstallDialogForTests(); }); - it("moves a confirmed installation through streamed progress stages", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "confirming", - version: "2026.10.0", - }); - - respondToRelayClientInstallConfirmation(true); - await expect(confirmation).resolves.toBe(true); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "installing", - version: "2026.10.0", - stage: "checking", - }); - - reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "installing", - version: "2026.10.0", - stage: "downloading", - }); - - finishRelayClientInstall(); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "closing", - view: { - status: "installing", - version: "2026.10.0", - stage: "downloading", - }, - }); - - completeRelayClientInstallDialogClose(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); - - it("returns to idle when installation is declined", async () => { - const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); - respondToRelayClientInstallConfirmation(false); - - await expect(confirmation).resolves.toBe(false); - expect(readRelayClientInstallDialogState()).toEqual({ - status: "closing", - view: { - status: "confirming", - version: "2026.10.0", - }, - }); - - completeRelayClientInstallDialogClose(); - expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); - }); - it("rejects concurrent confirmation with the active install state", async () => { const confirmation = requestRelayClientInstallConfirmation("2026.10.0"); respondToRelayClientInstallConfirmation(true); diff --git a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx index 2eae7bb8430c..305094898514 100644 --- a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx @@ -6,9 +6,10 @@ import { } from "@t3tools/contracts"; import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { AsyncResult } from "effect/reactivity"; -import { act, useLayoutEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useLayoutEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; const testState = vi.hoisted(() => ({ scopes: new Set(), @@ -105,29 +106,38 @@ const linkedState: EnvironmentCloudLinkStateResult = { publishAgentActivity: false, }; -let root: Root | null = null; -let controller: ReturnType | null = null; +type Controller = ReturnType; -function ControllerProbe() { +function ControllerProbe({ onRender }: { onRender: (controller: Controller) => void }) { const value = useCloudLinkController(); useLayoutEffect(() => { - controller = value; + onRender(value); }); return null; } async function mountController() { - root = createRoot(document.createElement("div")); - await act(async () => root!.render()); -} - -async function reconcile(desired: CloudLinkDesiredState) { - let succeeded = false; - await act(async () => { - if (controller === null) throw new Error("Controller is not mounted."); - succeeded = await controller.reconcileCloudState(desired); + let controller: Controller | null = null; + const screen = await render( + { + controller = value; + }} + />, + ); + return Object.assign(screen, { + get controller() { + return controller; + }, + async reconcile(desired: CloudLinkDesiredState) { + let succeeded = false; + await act(async () => { + if (controller === null) throw new Error("Controller is not mounted."); + succeeded = await controller.reconcileCloudState(desired); + }); + return succeeded; + }, }); - return succeeded; } function expectNoMutations() { @@ -136,9 +146,7 @@ function expectNoMutations() { expect(testState.preferences).not.toHaveBeenCalled(); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(console, "error").mockImplementation(() => undefined); +function setup() { testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); testState.snapshot = linkedState; testState.renderedSnapshot = linkedState; @@ -154,34 +162,29 @@ beforeEach(() => { } testState.refreshLink.mockReset(); testState.toast.mockReset(); -}); - -afterEach(async () => { - await act(async () => root?.unmount()); - root = null; - controller = null; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + return vi.spyOn(console, "error").mockImplementation(() => undefined); +} describe("useCloudLinkController", () => { it.each([AuthRelayReadScope, AuthRelayWriteScope])( "does not mutate without %s, even with a cached link snapshot", async (scope) => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.scopes.delete(scope); - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false); expect(testState.getToken).not.toHaveBeenCalled(); expectNoMutations(); }, ); it("does not interpret unavailable link state as an unlinked environment", async () => { + using _setup = setup(); testState.snapshot = null; - await mountController(); + await using screen = await mountController(); - expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: false, publish: true })).toBe(false); expect(testState.getToken).not.toHaveBeenCalled(); expectNoMutations(); }); @@ -189,123 +192,38 @@ describe("useCloudLinkController", () => { it.each([AuthRelayReadScope, AuthRelayWriteScope])( "rechecks %s after requesting the cloud token", async (scope) => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockImplementationOnce(async () => { testState.scopes.delete(scope); return "clerk-token"; }); - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false); + expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(false); expectNoMutations(); }, ); it("stops if the link-state read fails while requesting the cloud token", async () => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockImplementationOnce(async () => { testState.snapshot = null; return "clerk-token"; }); - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(await screen.reconcile({ managedTunnel: true, publish: true })).toBe(false); expectNoMutations(); }); - it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { - testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; - testState.renderedSnapshot = testState.snapshot; - await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = linkedState; - return "clerk-token"; - }); - - expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true); - expect(testState.link).not.toHaveBeenCalled(); - expect(testState.unlink).not.toHaveBeenCalled(); - expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ - target, - publishAgentActivity: true, - }); - }); - - it("shows a link the relay holds as publish-only as off and relinks it as managed", async () => { - testState.relayProviderKind = "manual"; - await mountController(); - - expect(controller?.managedTunnelActive).toBe(false); - expect(controller?.managedTunnelOutOfSync).toBe(true); - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).toHaveBeenCalledExactlyOnceWith({ - target, - clerkToken: "clerk-token", - mode: "managed", - }); - }); - - it("shows a revoked link as off and links it again when turned on", async () => { - testState.snapshot = { ...linkedState, linkRevoked: true }; - testState.renderedSnapshot = testState.snapshot; - await mountController(); - expect(controller).toMatchObject({ - linked: false, - linkRevoked: true, - managedTunnelActive: false, - managedTunnelOutOfSync: false, - }); - - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).toHaveBeenCalledExactlyOnceWith({ - target, - clerkToken: "clerk-token", - mode: "managed", - }); - }); - - it("keeps a managed link that the relay also holds as managed", async () => { - testState.relayProviderKind = "cloudflare_tunnel"; - await mountController(); - - expect(controller?.managedTunnelActive).toBe(true); - expect(controller?.managedTunnelOutOfSync).toBe(false); - expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); - expect(testState.link).not.toHaveBeenCalled(); - }); - - it("stops the preference write if management permission is lost after linking", async () => { - await mountController(); - testState.link.mockImplementationOnce(async () => { - testState.scopes.delete(AuthRelayWriteScope); - return AsyncResult.success({}); - }); - - expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); - expect(testState.link).toHaveBeenCalledOnce(); - expect(testState.preferences).not.toHaveBeenCalled(); - expect(testState.refreshLink).toHaveBeenCalledOnce(); - }); - it("still lets an authorized user unlink when the cloud token is unavailable", async () => { - await mountController(); + using _setup = setup(); + await using screen = await mountController(); testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable")); - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); - expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); - expect(testState.link).not.toHaveBeenCalled(); - expect(testState.preferences).not.toHaveBeenCalled(); - }); - - it("unlinks if the link-state read fails while requesting the cloud token", async () => { - await mountController(); - testState.getToken.mockImplementationOnce(async () => { - testState.snapshot = null; - throw new Error("Cloud sign-in unavailable"); - }); - - expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); + expect(await screen.reconcile({ managedTunnel: false, publish: false })).toBe(true); expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); - expect(testState.refreshLink).toHaveBeenCalledOnce(); }); }); diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 0007c079ad70..7d1748f9d0c4 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -1,3 +1,5 @@ +import { useParams } from "@tanstack/react-router"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { getAddProjectInitialQuery, getCloneDestinationPath, @@ -13,7 +15,9 @@ import { ArrowRightIcon } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { create } from "zustand"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { COPYABLE_FILE_MAX_BYTES, @@ -71,6 +75,8 @@ export function AddProjectToMachineDialogHost() { function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRequest }) { const id = useId(); const project = useProject(request.projectRef); + const handleNewThread = useNewThreadHandler(); + const { draftId: routeDraftId } = useParams({ strict: false }); const { environments } = useEnvironments(); const targets = useAddProjectTargets(project, useLogicalProjectEnvironments(project, false)); const [targetId, setTargetId] = useState(request.targetEnvironmentId); @@ -128,6 +134,30 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe setSelected(next); }; + // Lands the user on the new machine right away; its composer shows the clone + // and holds sends until it finishes. A draft open on the source project moves + // over with whatever was typed in it. + const openClonedProject = (targetRef: ScopedProjectRef) => { + const drafts = useComposerDraftStore.getState(); + const draftId = routeDraftId as DraftId | undefined; + const draft = draftId ? drafts.getDraftSession(draftId) : null; + if ( + draftId && + draft && + !draft.promotedTo && + draft.environmentId === request.projectRef.environmentId && + draft.projectId === request.projectRef.projectId + ) { + drafts.setDraftThreadContext(draftId, { + projectRef: targetRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + return; + } + void handleNewThread(targetRef); + }; + const submit = async () => { if (!project || !target || submitting) return; if (destinationPath.trim().length === 0) { @@ -136,6 +166,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe } setSubmitting(true); setError(null); + const projectId = newProjectId(); try { await addProjectToMachine({ source: { environmentId: project.environmentId, cwd: project.workspaceRoot }, @@ -144,13 +175,14 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe project.workspaceRoot, ), target: { environmentId: target.environmentId, label: target.label }, - projectId: newProjectId(), + projectId, title: project.title, remoteUrl, destinationPath: destinationPath.trim(), files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), }); close(); + openClonedProject(scopeProjectRef(target.environmentId, projectId)); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not start the clone."); setSubmitting(false); diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index dabb2abaeab4..2c4432583205 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,5 +1,3 @@ -"use client"; - import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../lib/observeResize"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index c991a9003514..89d52c87b282 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -321,7 +321,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { className="h-dvh! min-h-0!" data-panel-animations={routePanelAnimationsActive ? "true" : "false"} defaultOpen - mobileBreakpoint={sidebarMinimumWidth + THREAD_MAIN_CONTENT_MIN_WIDTH} style={sidebarProviderStyle} > diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 5d68ff76c2dc..d1ca6d01e4c3 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,115 +1,17 @@ -import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; +import { type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, - resolveEnvironmentOptionLabel, resolveBranchSelectionTarget, - resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, - resolveEnvModeLabel, - resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, - resolveBranchToolbarValue, - resolveLockedWorkspaceLabel, - resolveCheckoutBranchMismatch, - resolvePreviousWorktreeLabel, - resolvePreviousWorktreeSeed, + resolveLocalCheckoutBranchMismatch, sanitizeNewRefName, shouldIncludeBranchPickerItem, - shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -const localEnvironmentId = EnvironmentId.make("environment-local"); -const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - -describe("resolvePreviousWorktreeSeed", () => { - it("picks the most recently updated worktree thread", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/older", - worktreePath: "/repo/.t3/worktrees/older", - updatedAt: "2026-07-20T00:00:00.000Z", - }, - { - branch: "t3/newer", - worktreePath: "/repo/.t3/worktrees/newer", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - { branch: "main", worktreePath: null, updatedAt: "2026-07-23T00:00:00.000Z" }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/newer", worktreePath: "/repo/.t3/worktrees/newer" }); - }); - - it("skips the worktree the composer already points at", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/current", - worktreePath: "/repo/.t3/worktrees/current", - updatedAt: "2026-07-22T00:00:00.000Z", - }, - ], - currentWorktreePath: "/repo/.t3/worktrees/current", - }), - ).toBeNull(); - }); - - it("returns null when no thread has a worktree", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [{ branch: "main", worktreePath: null, updatedAt: "2026-07-22T00:00:00.000Z" }], - currentWorktreePath: null, - }), - ).toBeNull(); - }); - - it("ignores archived threads and threads with unparseable timestamps", () => { - expect( - resolvePreviousWorktreeSeed({ - threads: [ - { - branch: "t3/archived", - worktreePath: "/repo/.t3/worktrees/archived", - updatedAt: "2026-07-23T00:00:00.000Z", - archivedAt: "2026-07-23T01:00:00.000Z", - }, - { - branch: "t3/garbage-timestamp", - worktreePath: "/repo/.t3/worktrees/garbage", - updatedAt: "not-a-date", - }, - { - branch: "t3/live", - worktreePath: "/repo/.t3/worktrees/live", - updatedAt: "2026-07-21T00:00:00.000Z", - archivedAt: null, - }, - ], - currentWorktreePath: null, - }), - ).toEqual({ branch: "t3/live", worktreePath: "/repo/.t3/worktrees/live" }); - }); -}); - -describe("resolvePreviousWorktreeLabel", () => { - it("includes the branch when known", () => { - expect(resolvePreviousWorktreeLabel({ branch: "t3/fix-thing", worktreePath: "/wt" })).toBe( - "Previous worktree (t3/fix-thing)", - ); - expect(resolvePreviousWorktreeLabel({ branch: null, worktreePath: "/wt" })).toBe( - "Previous worktree", - ); - }); -}); - describe("resolveDraftEnvModeAfterBranchChange", () => { it("switches to local mode when returning from an existing worktree to the main worktree", () => { expect( @@ -121,16 +23,6 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("local"); }); - it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { - expect( - resolveDraftEnvModeAfterBranchChange({ - nextWorktreePath: null, - currentWorktreePath: null, - effectiveEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - it("uses worktree mode when selecting a ref already attached to a worktree", () => { expect( resolveDraftEnvModeAfterBranchChange({ @@ -142,146 +34,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { }); }); -describe("resolveBranchToolbarValue", () => { - it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { - expect( - resolveBranchToolbarValue({ - envMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: null, - currentGitBranch: "main", - }), - ).toBe("main"); - }); - - it("keeps an explicitly selected worktree base ref", () => { - expect( - resolveBranchToolbarValue({ - envMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).toBe("feature/base"); - }); - - it("shows the actual checked-out ref when not selecting a new worktree base", () => { - expect( - resolveBranchToolbarValue({ - envMode: "local", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", - }), - ).toBe("main"); - }); -}); - -describe("resolveBranchTriggerLabel", () => { - it("marks the remote base when a new worktree will start from remote", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromOrigin: true, - }), - ).toBe("From main (remote)"); - }); - - it("marks the remote base for local branch names that contain slashes", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "feature/demo", - resolvedActiveBranchIsRemote: false, - startFromOrigin: true, - }), - ).toBe("From feature/demo (remote)"); - }); - - it("shows the local ref when start from remote is disabled", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromOrigin: false, - }), - ).toBe("From main"); - }); - - it("does not add a remote marker for an explicit remote ref", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "origin/feature/demo", - resolvedActiveBranchIsRemote: true, - startFromOrigin: true, - }), - ).toBe("From origin/feature/demo"); - }); - - it("preserves an explicit ref from a non-origin remote", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "upstream/feature/demo", - resolvedActiveBranchIsRemote: true, - startFromOrigin: true, - }), - ).toBe("From upstream/feature/demo"); - }); - - it("keeps current-checkout labels and empty state unchanged", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "local", - resolvedActiveBranch: "main", - resolvedActiveBranchIsRemote: false, - startFromOrigin: true, - }), - ).toBe("main"); - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: null, - resolvedActiveBranchIsRemote: null, - startFromOrigin: true, - }), - ).toBe("Select ref"); - }); - - it("does not fabricate a remote marker while branch metadata is loading", () => { - expect( - resolveBranchTriggerLabel({ - activeWorktreePath: null, - effectiveEnvMode: "worktree", - resolvedActiveBranch: "upstream/feature/demo", - resolvedActiveBranchIsRemote: null, - startFromOrigin: true, - }), - ).toBe("From upstream/feature/demo"); - }); -}); - describe("resolveBranchToolbarPrBranch", () => { - it("uses the explicit thread branch when it matches the displayed branch", () => { - expect( - resolveBranchToolbarPrBranch({ - activeThreadBranch: "feature/current", - resolvedActiveBranch: "feature/current", - }), - ).toBe("feature/current"); - }); - it("hides PR state while an optimistic branch switch is in flight", () => { expect( resolveBranchToolbarPrBranch({ @@ -290,21 +43,12 @@ describe("resolveBranchToolbarPrBranch", () => { }), ).toBeNull(); }); - - it("does not infer PR state without an explicit thread branch", () => { - expect( - resolveBranchToolbarPrBranch({ - activeThreadBranch: null, - resolvedActiveBranch: "feature/current", - }), - ).toBeNull(); - }); }); -describe("resolveCheckoutBranchMismatch", () => { +describe("resolveLocalCheckoutBranchMismatch", () => { it("detects when a local thread is associated with a different branch than the checkout", () => { expect( - resolveCheckoutBranchMismatch({ + resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: "local", activeWorktreePath: null, activeThreadBranch: "feature/thread", @@ -313,254 +57,22 @@ describe("resolveCheckoutBranchMismatch", () => { ).toEqual({ threadBranch: "feature/thread", currentBranch: "feature/current", - canRestoreThreadBranch: true, - }); - }); - - it("ignores matching local checkout state", () => { - expect( - resolveCheckoutBranchMismatch({ - effectiveEnvMode: "local", - activeWorktreePath: null, - activeThreadBranch: "feature/thread", - currentGitBranch: "feature/thread", - }), - ).toBeNull(); - }); - - it("warns about worktree branch drift without restore, but ignores a pending rename status", () => { - const worktree = { - effectiveEnvMode: "worktree" as const, - activeWorktreePath: "/repo/.t3/worktrees/feature-thread", - activeThreadBranch: "t3code/fix-sidebar", - }; - - expect( - resolveCheckoutBranchMismatch({ - ...worktree, - currentGitBranch: "t3code/1a2b3c4d", - }), - ).toBeNull(); - expect( - resolveCheckoutBranchMismatch({ - ...worktree, - currentGitBranch: "feature/checkout", - }), - ).toEqual({ - threadBranch: worktree.activeThreadBranch, - currentBranch: "feature/checkout", - canRestoreThreadBranch: false, }); }); - it("ignores new-worktree base selection before a worktree exists", () => { + it("ignores dedicated worktrees because their checkout is already thread-scoped", () => { expect( - resolveCheckoutBranchMismatch({ + resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: "worktree", - activeWorktreePath: null, - activeThreadBranch: "feature/base", - currentGitBranch: "main", + activeWorktreePath: "/repo/.t3/worktrees/feature-thread", + activeThreadBranch: "feature/thread", + currentGitBranch: "feature/current", }), ).toBeNull(); }); }); -describe("resolveEnvironmentOptionLabel", () => { - it("prefers the primary environment's machine label", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: localEnvironmentId, - runtimeLabel: "Julius's Mac mini", - savedLabel: "Local environment", - }), - ).toBe("Julius's Mac mini"); - }); - - it("falls back to 'This device' for generic primary labels", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: localEnvironmentId, - runtimeLabel: "Local environment", - savedLabel: "Local", - }), - ).toBe("This device"); - }); - - it("keeps configured labels for non-primary environments", () => { - expect( - resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: remoteEnvironmentId, - runtimeLabel: null, - savedLabel: "Build box", - }), - ).toBe("Build box"); - }); -}); - -describe("shouldShowEnvironmentIndicator", () => { - it("shows the indicator whenever multiple environments are pickable", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: true, - }), - ).toBe(true); - }); - - it("shows a sole remote environment so the user knows where the project runs", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: false }, - canPickEnvironment: false, - }), - ).toBe(true); - }); - - it("hides a sole primary (this-device) environment", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: false, - }), - ).toBe(false); - }); - - it("hides the indicator when the active environment is unknown", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: null, - canPickEnvironment: false, - }), - ).toBe(false); - }); -}); - -describe("shouldShowComposerContextStrip", () => { - it("keeps a sole primary server update reachable in a non-Git draft", () => { - const environment = { - activeEnvironment: { isPrimary: true }, - canPickEnvironment: false, - serverUpdateAvailable: true, - }; - const showEnvironmentIndicator = shouldShowEnvironmentIndicator(environment); - expect(showEnvironmentIndicator).toBe(true); - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator, - hostsRestingComposerControls: false, - }), - ).toBe(true); - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: false, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator, - serverUpdateAvailable: true, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); - - it.each([false, true])( - "honors the active-thread preference with resting controls %s", - (hostsRestingComposerControls) => { - const input = { - isDraftHeroState: false, - hasActiveProject: true, - isGitRepo: true, - showEnvironmentIndicator: true, - hostsRestingComposerControls, - }; - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: false })).toBe( - false, - ); - expect(shouldShowComposerContextStrip({ ...input, persistInActiveThreads: true })).toBe(true); - }, - ); - - it("keeps the environment indicator visible for a non-Git project", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: true, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); - - it("hides the strip when a non-Git project has nothing to show", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: false, - hostsRestingComposerControls: false, - }), - ).toBe(false); - }); - - it("keeps the strip for visible resting composer controls in a non-Git thread", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: false, - showEnvironmentIndicator: false, - hostsRestingComposerControls: true, - }), - ).toBe(true); - }); - - it("shows Git controls without requiring an environment indicator", () => { - expect( - shouldShowComposerContextStrip({ - isDraftHeroState: true, - persistInActiveThreads: false, - hasActiveProject: true, - isGitRepo: true, - showEnvironmentIndicator: false, - hostsRestingComposerControls: false, - }), - ).toBe(true); - }); -}); - describe("resolveEffectiveEnvMode", () => { - it("treats draft threads already attached to a worktree as current-checkout mode", () => { - expect( - resolveEffectiveEnvMode({ - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - hasServerThread: false, - draftThreadEnvMode: "worktree", - }), - ).toBe("local"); - }); - - it("keeps explicit new-worktree mode for draft threads without a worktree path", () => { - expect( - resolveEffectiveEnvMode({ - activeWorktreePath: null, - hasServerThread: false, - draftThreadEnvMode: "worktree", - }), - ).toBe("worktree"); - }); - it("keeps a server thread in worktree mode while its worktree is still being created", () => { expect( resolveEffectiveEnvMode({ @@ -580,54 +92,12 @@ describe("resolveEffectiveEnvMode", () => { }); }); -describe("resolveEnvModeLabel", () => { - it("uses explicit workspace labels", () => { - expect(resolveEnvModeLabel("local")).toBe("Current checkout"); - expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); - }); -}); - -describe("resolveCurrentWorkspaceLabel", () => { - it("describes the main repo checkout when no worktree path is active", () => { - expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); - }); - - it("describes the active checkout as a worktree when one is attached", () => { - expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); - }); -}); - -describe("resolveLockedWorkspaceLabel", () => { - it("uses a shorter label for the main repo checkout", () => { - expect(resolveLockedWorkspaceLabel(null, "local")).toBe("Local checkout"); - }); - - it("uses a shorter label for an attached worktree", () => { - expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", "worktree")).toBe( - "Worktree", - ); - }); - - it("describes a worktree that is still being created as a new worktree", () => { - expect(resolveLockedWorkspaceLabel(null, "worktree")).toBe("New worktree"); - }); -}); - describe("deriveLocalBranchNameFromRemoteRef", () => { - it("strips the remote prefix from a remote ref", () => { - expect(deriveLocalBranchNameFromRemoteRef("origin/feature/demo")).toBe("feature/demo"); - }); - it("supports remote names that contain slashes", () => { expect(deriveLocalBranchNameFromRemoteRef("my-org/upstream/feature/demo")).toBe( "upstream/feature/demo", ); }); - - it("returns the original name when ref is malformed", () => { - expect(deriveLocalBranchNameFromRemoteRef("origin/")).toBe("origin/"); - expect(deriveLocalBranchNameFromRemoteRef("/feature/demo")).toBe("/feature/demo"); - }); }); describe("dedupeRemoteBranchesWithLocalMatches", () => { @@ -663,54 +133,6 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { ]); }); - it("keeps all entries when no local match exists for a remote ref", () => { - const input: VcsRef[] = [ - { - name: "feature/local", - current: false, - isDefault: false, - worktreePath: null, - }, - { - name: "origin/feature/remote-only", - isRemote: true, - remoteName: "origin", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ - "feature/local", - "origin/feature/remote-only", - ]); - }); - - it("keeps non-origin remote refs visible even when a matching local ref exists", () => { - const input: VcsRef[] = [ - { - name: "feature/demo", - current: false, - isDefault: false, - worktreePath: null, - }, - { - name: "my-org/upstream/feature/demo", - isRemote: true, - remoteName: "my-org/upstream", - current: false, - isDefault: false, - worktreePath: null, - }, - ]; - - expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ - "feature/demo", - "my-org/upstream/feature/demo", - ]); - }); - it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { const input: VcsRef[] = [ { @@ -771,23 +193,6 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("checks out the default ref in the main repo when leaving a secondary worktree", () => { - expect( - resolveBranchSelectionTarget({ - activeProjectCwd: "/repo", - activeWorktreePath: "/repo/.t3/worktrees/feature-a", - refName: { - isDefault: true, - worktreePath: null, - }, - }), - ).toEqual({ - checkoutCwd: "/repo", - nextWorktreePath: null, - reuseExistingWorktree: false, - }); - }); - it("keeps checkout in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ @@ -818,28 +223,6 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "__create_new_branch__:feature/demo", - normalizedQuery: "feature/demo", - createBranchItemValue: "__create_new_branch__:feature/demo", - checkoutPullRequestItemValue: null, - }), - ).toBe(true); - }); - - it("still filters ordinary ref items by query text", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "main", - normalizedQuery: "gh pr checkout 1359", - createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", - checkoutPullRequestItemValue: "__checkout_pull_request__:1359", - }), - ).toBe(false); - }); - it("surfaces an existing ref matching the sanitized query", () => { expect( shouldIncludeBranchPickerItem({ @@ -850,43 +233,13 @@ describe("shouldIncludeBranchPickerItem", () => { }), ).toBe(true); }); - - it("surfaces a ref from a partial query containing a space", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "hello-world", - normalizedQuery: "hello w", - createBranchItemValue: null, - checkoutPullRequestItemValue: null, - }), - ).toBe(true); - }); - - it("excludes refs matching neither the raw nor the sanitized query", () => { - expect( - shouldIncludeBranchPickerItem({ - itemValue: "main", - normalizedQuery: "new branch", - createBranchItemValue: null, - checkoutPullRequestItemValue: null, - }), - ).toBe(false); - }); }); describe("sanitizeNewRefName", () => { - it("replaces a space with a dash", () => { - expect(sanitizeNewRefName("new branch")).toBe("new-branch"); - }); - it("collapses a run of whitespace into a single dash", () => { expect(sanitizeNewRefName("new branch")).toBe("new-branch"); }); - it("trims surrounding whitespace instead of turning it into dashes", () => { - expect(sanitizeNewRefName(" new branch ")).toBe("new-branch"); - }); - it("replaces tabs, which git rejects just like spaces", () => { expect(sanitizeNewRefName("new\tbranch")).toBe("new-branch"); }); @@ -896,22 +249,6 @@ describe("sanitizeNewRefName", () => { expect(sanitizeNewRefName("new\u2009branch")).toBe("new\u2009branch"); }); - it("keeps slashes so nested ref names survive", () => { - expect(sanitizeNewRefName("feature/new thing")).toBe("feature/new-thing"); - }); - - it("preserves case because git ref names are case sensitive", () => { - expect(sanitizeNewRefName("Feature/New Thing")).toBe("Feature/New-Thing"); - }); - - it("leaves an already valid ref name untouched", () => { - expect(sanitizeNewRefName("feature/login")).toBe("feature/login"); - }); - - it("returns an empty string for whitespace-only input", () => { - expect(sanitizeNewRefName(" ")).toBe(""); - }); - it("does not collapse dashes the user typed", () => { expect(sanitizeNewRefName("new - branch")).toBe("new---branch"); expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 13775843b642..24db217304a5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -6,7 +6,7 @@ import type { WorktreeSubmodules, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { isTemporaryWorktreeBranch, sanitizeNewRefName } from "@t3tools/shared/git"; +import { sanitizeNewRefName } from "@t3tools/shared/git"; import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches, @@ -59,9 +59,8 @@ export function resolveEnvironmentOptionLabel(input: { export function shouldShowEnvironmentIndicator(input: { activeEnvironment: Pick | null; canPickEnvironment: boolean; - serverUpdateAvailable?: boolean; }): boolean { - if (input.canPickEnvironment || input.serverUpdateAvailable) return true; + if (input.canPickEnvironment) return true; return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } @@ -72,13 +71,10 @@ export function shouldShowComposerContextStrip(input: { isGitRepo: boolean; showEnvironmentIndicator: boolean; hostsRestingComposerControls: boolean; - serverUpdateAvailable?: boolean; }): boolean { return ( input.hasActiveProject && - (input.isDraftHeroState || - input.persistInActiveThreads || - input.serverUpdateAvailable === true) && + (input.isDraftHeroState || input.persistInActiveThreads) && (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) ); } @@ -238,35 +234,20 @@ export function resolveBranchToolbarPrBranch(input: { return input.activeThreadBranch === input.resolvedActiveBranch ? input.activeThreadBranch : null; } -export function resolveCheckoutBranchMismatch(input: { +export function resolveLocalCheckoutBranchMismatch(input: { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; activeThreadBranch: string | null; currentGitBranch: string | null; -}) { +}): { threadBranch: string; currentBranch: string } | null { const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; - const isLocalCheckout = effectiveEnvMode === "local" && activeWorktreePath === null; - const isEstablishedWorktree = effectiveEnvMode === "worktree" && activeWorktreePath !== null; - if (!isLocalCheckout && !isEstablishedWorktree) { + if (effectiveEnvMode !== "local" || activeWorktreePath !== null) { return null; } if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) { return null; } - // Cached status can predate the automatic rename of a temporary worktree branch. - if ( - isEstablishedWorktree && - !isTemporaryWorktreeBranch(activeThreadBranch) && - isTemporaryWorktreeBranch(currentGitBranch) - ) { - return null; - } - return { - threadBranch: activeThreadBranch, - currentBranch: currentGitBranch, - // Restoring a worktree checkout could switch the branch for sibling threads. - canRestoreThreadBranch: isLocalCheckout, - }; + return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch }; } export function resolveBranchSelectionTarget(input: { diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx deleted file mode 100644 index 51ce200a53e6..000000000000 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; - -import "~/index.css"; - -const state = vi.hoisted(() => ({ - worktreePath: "/tmp/worktree" as string | null, - showContextMenu: vi.fn(), - writeTextToClipboard: vi.fn(), -})); - -vi.mock(import("../localApi"), async (original) => ({ - ...(await original()), - readLocalApi: (() => ({ contextMenu: { show: state.showContextMenu } })) as never, -})); -vi.mock(import("../hooks/useCopyToClipboard"), async (original) => ({ - ...(await original()), - writeTextToClipboard: state.writeTextToClipboard, - useCopyToClipboard: ((options: { target: string }) => ({ - copyToClipboard: (command: string) => state.writeTextToClipboard(command, options.target), - isCopied: false, - })) as never, -})); -vi.mock(import("./BranchToolbarBranchSelector"), async (original) => ({ - ...(await original()), - BranchToolbarBranchSelector: (() => null) as never, -})); -vi.mock(import("../state/entities"), async (original) => ({ - ...(await original()), - useProject: (() => ({ workspaceRoot: "/tmp/project" })) as never, - useThreadShellsForProjectRefs: () => [], -})); - -import { BranchToolbar } from "./BranchToolbar"; -import { ComposerSurface } from "./chat/ComposerSurface"; -import type { ServerUpdateTarget } from "./ServerUpdateAction"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.worktreePath = "/tmp/worktree"; - state.showContextMenu.mockReset().mockResolvedValue("copy-path"); - state.writeTextToClipboard.mockReset().mockResolvedValue(true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -function render(props: Partial>) { - return act(async () => - root.render( - - - , - ), - ); -} - -it("keeps machine choices usable when the combined row's workspace is locked", async () => { - const onEnvironmentChange = vi.fn(); - await render({ - onEnvironmentChange, - availableEnvironments: ["local", "remote"].map((id) => ({ - environmentId: EnvironmentId.make(id), - projectId: ProjectId.make("project"), - label: id, - isPrimary: id === "local", - machine: "server", - })), - }); - const trigger = container.querySelector("button")!; - expect(trigger.textContent).toBe("local"); - expect(container.querySelectorAll("button")).toHaveLength(1); - - // A ctrl-click is a macOS secondary click; it must not open the menu. - await act(async () => { - trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, ctrlKey: true })); - }); - expect(document.querySelector('[role="menu"]')).toBeNull(); - - await userEvent.click(trigger); - await expect - .element(page.getByRole("menuitemradio", { name: "New worktree" })) - .toHaveAttribute("aria-disabled", "true"); - await userEvent.click(page.getByRole("menuitemradio", { name: "remote" })); - expect(onEnvironmentChange).toHaveBeenCalledWith("remote"); -}); - -it("shows a started thread's primary machine as a static label in the composer", async () => { - await render({ - layout: "composer", - showGitControls: false, - availableEnvironments: ["local", "remote"].map((id) => ({ - environmentId: EnvironmentId.make(id), - projectId: ProjectId.make("project"), - label: id, - isPrimary: id === "local", - machine: "server", - })), - }); - expect(container.textContent).toBe("local"); - expect(container.querySelector("button")).toBeNull(); -}); - -it.each([ - ["local", null, "/tmp/project"], - ["worktree", null, null], - ["worktree", "/tmp/worktree", "/tmp/worktree"], - ["worktree", "C:/repo/worktree", String.raw`C:\repo\worktree`], -] as const)( - "copies only an existing workspace path with mode %s and worktree %s", - async (envMode, worktreePath, copiedPath) => { - state.worktreePath = worktreePath; - await render({ envMode }); - const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - await act(async () => { - container.querySelector('[aria-label="Run context"]')!.dispatchEvent(event); - }); - expect(event.defaultPrevented).toBe(copiedPath !== null); - if (copiedPath === null) { - expect(state.showContextMenu).not.toHaveBeenCalled(); - expect(state.writeTextToClipboard).not.toHaveBeenCalled(); - } else { - await vi.waitFor(() => - expect(state.writeTextToClipboard).toHaveBeenCalledWith(copiedPath, "workspace path"), - ); - } - }, -); - -it.each([ - { layout: "wide Git", width: 900, showGitControls: true }, - { layout: "narrow Git", width: 360, showGitControls: true }, - { layout: "sole primary non-Git", width: 360, showGitControls: false }, -])( - "copies the pending update from the $layout composer chip", - async ({ width, showGitControls }) => { - await page.viewport(1000, 700); - container.style.width = `${width}px`; - container.style.setProperty("--chat-content-max-width", `${width}px`); - const serverUpdate = { - environmentId: EnvironmentId.make("local"), - serverLabel: "Build box", - serverVersion: "0.0.30", - targetVersion: "0.0.31", - selfUpdate: null, - installation: { kind: "npm-global", prefix: "/opt/node" }, - } satisfies ServerUpdateTarget & { serverVersion: string }; - await render({ - layout: "composer", - envLocked: showGitControls, - showGitControls, - availableEnvironments: [ - { - environmentId: serverUpdate.environmentId, - projectId: ProjectId.make("project"), - label: serverUpdate.serverLabel, - isPrimary: true, - machine: "server", - }, - ], - serverUpdate, - }); - - expect( - container.querySelector('[data-slot="composer-shell"]')!.getBoundingClientRect().width, - ).toBe(width); - const chip = page.getByRole("button", { name: /Build box|Copy update command/ }); - await expect.element(chip).toBeVisible(); - await userEvent.hover(chip); - await expect.element(page.getByRole("tooltip")).toMatchTextContent("0.0.30"); - await expect.element(page.getByRole("tooltip")).toMatchTextContent("0.0.31"); - await userEvent.click(chip); - await vi.waitFor(() => - expect(state.writeTextToClipboard).toHaveBeenCalledWith( - "npm install --global --prefix '/opt/node' t3@0.0.31", - "update command", - ), - ); - }, -); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 2447ae3e1ebd..d4116565c8a1 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -47,7 +47,6 @@ import { import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { openAddProjectToMachine } from "./AddProjectToMachineDialog"; import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets"; -import type { PendingServerUpdate } from "./ServerUpdateAction"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; @@ -112,7 +111,6 @@ interface BranchToolbarProps { onEnvironmentChange?: (environmentId: EnvironmentId) => void; composerControlsHostRef?: (element: HTMLDivElement | null) => void; contextStripVisible?: boolean; - serverUpdate?: PendingServerUpdate | null; } function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { @@ -634,7 +632,6 @@ export const BranchToolbar = memo(function BranchToolbar({ onEnvironmentChange, composerControlsHostRef, contextStripVisible = true, - serverUpdate = null, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); @@ -700,7 +697,6 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, - serverUpdateAvailable: serverUpdate !== null, }); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); @@ -767,18 +763,6 @@ export const BranchToolbar = memo(function BranchToolbar({ !contextStripVisible && "pointer-events-none invisible absolute inset-x-0 top-full", )} > - {serverUpdate && availableEnvironments ? ( - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - serverUpdate={serverUpdate} - /> - ) : null} {showGitControls ? (
) : null} - {showGitControls || (showEnvironmentIndicator && serverUpdate === null) ? ( + {showGitControls || showEnvironmentIndicator ? (
{showEnvironmentIndicator && availableEnvironments && ( <> - {serverUpdate === null ? ( - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - /> - ) : null} + 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} + /> {showGitControls ? ( void; onAddToMachine?: (() => void) | undefined; - serverUpdate?: PendingServerUpdate | null; } // Action values cannot collide with server-minted environment IDs. const AUTO_VALUE = "action:auto"; -const UPDATE_VALUE = "action:update"; const ADD_MACHINE_VALUE = "action:add-machine"; const environmentValue = (id: EnvironmentId) => `env:${id}`; -export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector( - props: BranchToolbarEnvironmentSelectorProps, -) { - return props.serverUpdate ? ( - } - /> - ) : ( - - ); -}); - -function EnvironmentSelector({ +export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ autoEnvironmentLabel, onAutoEnvironment, envLocked, @@ -63,11 +43,7 @@ function EnvironmentSelector({ availableEnvironments, onEnvironmentChange, onAddToMachine, - serverUpdate, - updateControl, -}: BranchToolbarEnvironmentSelectorProps & { - updateControl: ServerUpdateControl | null; -}) { +}: BranchToolbarEnvironmentSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const shortcutLabel = shortcutLabelForCommand( useAtomValue(primaryServerKeybindingsAtom), @@ -84,34 +60,8 @@ function EnvironmentSelector({ value: environmentValue(env.environmentId), label: env.label, })), - ...(serverUpdate && updateControl - ? [{ value: UPDATE_VALUE, label: `${updateControl.label} ${serverUpdate.serverLabel}` }] - : []), ...(onAddToMachine ? [{ value: ADD_MACHINE_VALUE, label: "Add to another machine" }] : []), ]; - const updateTooltip = serverUpdate - ? `${serverUpdate.serverLabel} ${serverUpdate.serverVersion} → ${serverUpdate.targetVersion}. ${ - serverUpdate.selfUpdate === "desktop-managed" && !serverUpdate.desktopAppUpdate - ? "Update the desktop app on that machine." - : (updateControl?.label ?? "Update") - }` - : null; - const environmentIcon = serverUpdate ? ( - ", "", "", "", "", ""])( - "preserves unmatched %s without linking later blocks", - async (token) => { - const container = await renderText( - `- **"From ${token}"** appears in the header.\n\n- **Tests:** cover inheritance.\n\nThe deferred move continues on B.\n\nSee the link.`, - ); - expect(container.querySelector("strong")?.textContent).toBe(`"From ${token}"`); - expect(linkTexts(container)).toEqual(["the link"]); - expect(container.querySelectorAll("li")).toHaveLength(2); - expect([...container.querySelectorAll("p")].map((p) => p.textContent)).toContain( - "The deferred move continues on B.", - ); - }, - ); - - it.each([" ", "
more
\n"])( - "preserves a paired anchor closing in the raw block %s", - async (closing) => { - const container = await renderText(`See label\n\n${closing}\n\nfinish`); - expect(container.querySelector("p")?.textContent).toBe("See label"); - }, - ); - - it("preserves a paired anchor after comment-looking raw text", async () => { - const container = await renderText("See label -->"); - expect(container.querySelector("p")?.textContent).toBe("See label -->"); - }); - - it.each(["", '
more
', ''])( - "ignores apparent closing anchors inside %s", - async (html) => { - const container = await renderText(`Before .\n\n${html}\n\nAfter.`); - expect(container.querySelector("p")?.textContent).toBe("Before ."); - expect(container.querySelectorAll("a")).toHaveLength(0); - }, - ); - - it("preserves paired HTML anchors, details, markdown links, and inline code", async () => { - const container = await renderText( - 'Bare label, , ``, and [docs](https://example.com).\n\n
MoreDetails
', - ); - expect(linkTexts(container)).toEqual(["label", "", "docs"]); - expect(container.querySelector("code")?.textContent).toBe("
"); - expect(container.querySelector("[data-markdown-details]")?.textContent).toContain("More"); - }); -}); - -describe("ChatMarkdown Mermaid streaming", () => { - it("reveals a completed fence while streaming and preserves it through later prose and stream end", async () => { - let finishRender!: (label: string) => void; - renderDiagram.mockReturnValue( - new Promise((resolve) => { - finishRender = resolve; - }), - ); - const source = "flowchart LR\nA --> B"; - const open = `\`\`\`mermaid\n${source}`; - const closed = `${open}\n\`\`\`\n\n`; - const message = (text: string, isStreaming = true) => ( - - ); - const { container, rerender } = await render(message(open)); - expect(container.querySelector("pre")?.textContent).toContain(source); - expect(renderDiagram).not.toHaveBeenCalled(); - - await rerender(message(closed)); - expect(renderDiagram).toHaveBeenCalledWith(expect.stringContaining(source)); - expect(container.querySelector('svg[role="img"]')).toBeNull(); - expect(container.querySelector(".chat-markdown-shiki")).toBeNull(); - const placeholder = container.querySelector("pre"); - expect(placeholder?.getAttribute("aria-hidden")).toBe("true"); - expect(placeholder?.textContent).toContain(source); - - await act(async () => finishRender("Completed diagram")); - const diagram = container.querySelector('svg[role="img"]'); - expect(diagram?.getAttribute("aria-label")).toBe("Completed diagram"); - for (const streaming of [true, false]) { - await rerender(message(`${closed}The remaining explanation.`, streaming)); - expect(container.querySelector('svg[role="img"]')).toBe(diagram); - expect(container.textContent).toContain("The remaining explanation."); - } - - await act(async () => buttonsLabelled(container, "Show code")[0]!.click()); - expect(container.querySelector('svg[role="img"]')).toBeNull(); - expect(container.querySelector("pre")?.textContent).toContain(source); - await act(async () => buttonsLabelled(container, "Show diagram")[0]!.click()); - expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe( - "Completed diagram", - ); - }); - - it.each([ - ["short tilde marker", "~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"], - [ - "overindented blockquote marker", - "> ```mermaid\n> flowchart LR\n> A --> B\n> ```", - "\n> ```", - ], - [ - "overindented list marker", - "- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", - "\n ```", - ], - ])("keeps a %s in source until a real closer arrives", async (_, open, closing) => { - const message = (text: string) => ; - const { container, rerender } = await render(message(open)); - expect(renderDiagram).not.toHaveBeenCalled(); - expect(container.querySelector("pre")?.textContent).toContain("A --> B"); - - await rerender(message(`${open}${closing}`)); - expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe("Diagram"); - }); - - it("does not mistake a CRLF stream cut after CR for a closed fence", async () => { - const open = "```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```"; - const message = (text: string) => ; - const { container, rerender } = await render(message(open)); - expect(renderDiagram).not.toHaveBeenCalled(); - await rerender(message(`${open}\r`)); - expect(renderDiagram).not.toHaveBeenCalled(); - expect(container.querySelector("pre")?.textContent).toContain("A --> B"); - - await rerender(message(`${open}\r\n\`\`\``)); - expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe("Diagram"); - }); - - it("falls back to source when a completed diagram fails during streaming", async () => { - await getSyntaxHighlighterPromise("mermaid"); - renderDiagram.mockImplementation(() => { - throw new Error("Invalid Mermaid diagram"); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - const container = await renderText("```mermaid\ninvalid diagram\n```", { isStreaming: true }); - expect(renderDiagram).toHaveBeenCalledWith(expect.stringContaining("invalid diagram")); - expect(container.querySelector('svg[role="img"]')).toBeNull(); - expect(container.querySelector("pre")?.textContent).toContain("invalid diagram"); - expect(container.querySelector("pre")?.getAttribute("aria-hidden")).not.toBe("true"); - }); -}); - -describe("ChatMarkdown context references", () => { - it("renders text and image references through the chip renderer, with readable fallback", async () => { - const text = - "See [Terminal output](t3-context://v1/terminal/term-1) and ![Error image](t3-context://v1/image/img-1)."; - const { container, rerender } = await render( - ( - - )} - />, - ); - expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual([ - "terminal: Terminal output", - "image: Error image", - ]); - expect(container.querySelectorAll("img, a")).toHaveLength(0); - await rerender(); - expect(container.textContent).toBe("See Terminal output and Error image."); - expect(container.querySelectorAll("img, a, button")).toHaveLength(0); - }); - - it("reads formatted context labels through nested markup instead of the context id", async () => { - const seen: Array = []; - await render( - { - seen.push(`${kind}: ${label}`); - return ; - }} - />, - ); - expect(new Set(seen)).toEqual(new Set(["terminal: Bold code"])); - }); -}); - describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { + setup(); const markdown = (url: string) => ; - const { container, rerender } = await render(markdown("https://example.com")); + await using screen = await render(markdown("https://example.com")); + const { container, rerender } = screen; expect([...container.querySelectorAll("img")].map((image) => image.src)).toEqual([ "https://www.google.com/s2/favicons?domain=example.com&sz=32", ]); @@ -451,6 +115,7 @@ describe("ChatMarkdown favicon privacy", () => { describe("ChatMarkdown streaming", () => { it("runs only a complete single-line shell block after a click", async () => { + setup(); const onRunShellCommand = vi.fn(); const message = (text: string, isStreaming = false) => ( { onRunShellCommand={onRunShellCommand} /> ); - const { container, rerender } = await render(message("```bash\necho hello\n```", true)); + await using screen = await render(message("```bash\necho hello\n```", true)); + const { container, rerender } = screen; const runButtons = () => buttonsLabelled(container, "Run in terminal"); expect(runButtons()).toHaveLength(0); @@ -496,113 +162,8 @@ describe("ChatMarkdown streaming", () => { } }); - it("does not retokenize completed lines when streaming finishes", async () => { - const highlighter = await getSyntaxHighlighterPromise("typescript"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - const text = "```typescript\nconst completed = 1;\nconst current = 2;"; - const { rerender } = await render(); - await vi.waitFor(() => expect(highlight).toHaveBeenCalled()); - highlight.mockClear(); - await rerender(); - expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); - }); - - it("recovers highlighting after a failed fence changes without resetting its controls", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHast = highlighter.codeToHast.bind(highlighter); - let fail = true; - vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { - if (fail) throw new Error("Temporary highlighter failure"); - return codeToHast(...args); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - - const { container, rerender } = await render( - , - ); - const codeBlock = container.querySelector('[data-language="text"]')!; - const initialWrap = codeBlock.dataset.wrap === "true"; - await act(async () => - buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(), - ); - expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(0); - - fail = false; - await rerender( - , - ); - await vi.waitFor(() => - expect(container.querySelectorAll(".chat-markdown-shiki")).toHaveLength(1), - ); - expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); - expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); - }); - - it("preserves code controls and details without highlighting an unchanged fence again", async () => { - const highlighter = await getSyntaxHighlighterPromise("text"); - const highlight = vi.spyOn(highlighter, "codeToHast"); - const writeText = vi - .spyOn(navigator.clipboard, "writeText") - .mockImplementation(async () => undefined); - const text = [ - "```text", - "First code block", - "```", - "", - "
More", - "", - "Details content", - "", - "
", - "", - "Streaming reply", - ].join("\n"); - - const { container, rerender } = await render( - , - ); - const codeBlock = container.querySelector('[data-language="text"]')!; - const initialWrap = codeBlock.dataset.wrap === "true"; - await act(async () => { - buttonsLabelled(container, initialWrap ? "Disable line wrap" : "Wrap lines")[0]!.click(); - buttonsLabelled(container, "Copy code")[0]!.click(); - }); - await act(async () => - container.querySelector("[data-markdown-details-summary]")!.click(), - ); - const details = container.querySelector("[data-markdown-details]")!; - expect(details.dataset.markdownDetailsOpen).toBe("true"); - expect(container.textContent).toContain("Details content"); - expect(writeText).toHaveBeenCalledWith("First code block\n"); - expect(highlight).toHaveBeenCalledTimes(1); - - for (let index = 0; index < 10; index += 1) { - await rerender(); - } - - expect(highlight).toHaveBeenCalledTimes(1); - expect(container.querySelector('[data-language="text"]')).toBe(codeBlock); - expect(codeBlock.dataset.wrap).toBe(String(!initialWrap)); - expect(container.querySelector("[data-markdown-details]")).toBe(details); - expect(details.dataset.markdownDetailsOpen).toBe("true"); - await rerender( - , - ); - const copy = [ - ...buttonsLabelled(container, "Copied"), - ...buttonsLabelled(container, "Copy code"), - ][0]!; - await act(async () => copy.click()); - expect(writeText).toHaveBeenLastCalledWith("Updated code block\n"); - expect(highlight).toHaveBeenCalledTimes(2); - }); - it("edits the current task text and marker after reusing a renderer", async () => { + setup(); let editedText: string | undefined; let rerender: (next: ReactElement) => Promise = async () => {}; const message = (text: string): ReactElement => ( @@ -615,7 +176,7 @@ describe("ChatMarkdown streaming", () => { }} /> ); - const rendered = await render(message("- [ ] First\n- [ ] Second")); + await using rendered = await render(message("- [ ] First\n- [ ] Second")); rerender = rendered.rerender; const { container } = rendered; const checkboxes = () => container.querySelectorAll('input[type="checkbox"]'); @@ -632,7 +193,8 @@ describe("ChatMarkdown streaming", () => { describe("ChatMarkdown skill chips", () => { it("updates digit-leading skill labels when discovered skills change", async () => { const text = "Use $2spec with a $20k budget."; - const { container, rerender } = await render(); + await using screen = await render(); + const { container, rerender } = screen; expect(container.textContent).not.toContain("2Spec"); await rerender( @@ -659,21 +221,23 @@ describe("ChatMarkdown skill chips", () => { environmentId: EnvironmentId.make("workspace"), threadId: ThreadId.make("thread"), }; - const openFile = vi + using openFile = vi .spyOn(useRightPanelStore.getState(), "openFile") .mockImplementation(() => {}); - const container = await renderText("$eod-brief Run today's brief, not `$eod-brief`.", { + // Held as a variable so the extra preview fields stay on the object. The prop + // type only names the label, and a fresh literal would be rejected. + const skill = { + name: "eod-brief", + displayName: "Eod Brief", + shortDescription: "Short", + description: "Drafts the **end of day** brief.", + path: "/skills/eod-brief/SKILL.md", + }; + await using screen = await renderText("$eod-brief Run today's brief, not `$eod-brief`.", { threadRef, - skills: [ - { - name: "eod-brief", - displayName: "Eod Brief", - shortDescription: "Short", - description: "Drafts the **end of day** brief.", - path: "/skills/eod-brief/SKILL.md", - }, - ], + skills: [skill], }); + const { container } = screen; const chips = buttonsLabelled(container, "Skill, Eod Brief. Show details"); expect(chips).toHaveLength(1); @@ -695,127 +259,8 @@ describe("ChatMarkdown workspace file opens", () => { threadId: ThreadId.make("thread"), }; - it.each([ - { - cwd: "/school/courses/physics", - path: "courses/physics/notes/a.md", - indexedPath: "notes/a.md", - }, - { - cwd: "C:/school/courses/Physics", - path: "courses/physics/notes/a.md", - indexedPath: "Notes/A.md", - }, - ])( - "opens a repo-relative chip at its indexed path in $cwd", - async ({ cwd, path, indexedPath }) => { - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [ - { path: indexedPath, kind: "file" }, - { path, kind: "file" }, - ], - truncated: false, - }), - ); - const openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - const container = await renderText(`[Notes](${path}:7)`, { cwd, threadRef }); - - await act(async () => container.querySelector("a")!.click()); - - expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, indexedPath, 7); - }, - ); - - it("prefers an existing literal nested file over the stripped Windows match", async () => { - filesystemMetadata.mockResolvedValue( - AsyncResult.success({ entries: [{ kind: "file", byteLength: 10 }] }), - ); - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [ - { path: "Notes/A.md", kind: "file" }, - { path: "Physics/Notes/A.md", kind: "file" }, - ], - truncated: false, - }), - ); - const openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - const container = await renderText("[Notes](physics/notes/a.md)", { - cwd: "C:/school/Physics", - threadRef, - }); - - await act(async () => container.querySelector("a")!.click()); - - expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", undefined); - }); - - it("keeps a literal file omitted by a truncated search", async () => { - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [{ path: "notes/a.md", kind: "file" }], - truncated: true, - }), - ); - filesystemMetadata.mockResolvedValue( - AsyncResult.success({ entries: [{ kind: "file", byteLength: 10 }] }), - ); - const openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - const container = await renderText("[Notes](physics/notes/a.md:7)", { - cwd: "/school/physics", - threadRef, - }); - - await act(async () => container.querySelector("a")!.click()); - - expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", 7); - expect(filesystemMetadata).toHaveBeenCalledExactlyOnceWith({ - environmentId: threadRef.environmentId, - input: { paths: ["/school/physics/physics/notes/a.md"] }, - }); - }); - - it.each([ - { - kind: "absolute link", - href: "/school/physics/physics/notes/a.md:7", - imageBaseDir: undefined, - }, - { kind: "nested preview", href: "notes/a.md:7", imageBaseDir: "/school/physics/physics" }, - ])( - "keeps the literal target for a $kind when only its stripped twin exists", - async ({ href, imageBaseDir }) => { - workspaceSearch.mockResolvedValue( - AsyncResult.success({ - entries: [{ path: "notes/a.md", kind: "file" }], - truncated: false, - }), - ); - const openFile = vi - .spyOn(useRightPanelStore.getState(), "openFile") - .mockImplementation(() => {}); - const container = await renderText(`[Notes](${href})`, { - cwd: "/school/physics", - imageBaseDir, - threadRef, - }); - - await act(async () => container.querySelector("a")!.click()); - - expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "physics/notes/a.md", 7); - expect(workspaceSearch).not.toHaveBeenCalled(); - expect(filesystemMetadata).not.toHaveBeenCalled(); - }, - ); - it("keeps a newer absolute file open when an earlier repo-relative lookup finishes", async () => { + setup(); const result = AsyncResult.success({ entries: [{ path: "notes/a.md", kind: "file" }], truncated: false, @@ -825,16 +270,17 @@ describe("ChatMarkdown workspace file opens", () => { resolvePending = resolve; }); workspaceSearch.mockReturnValueOnce(pending); - const openFile = vi + using openFile = vi .spyOn(useRightPanelStore.getState(), "openFile") .mockImplementation(() => {}); - const container = await renderText( + await using screen = await renderText( "[Notes](physics/notes/a.md) and [Other](/elsewhere/b.md:9)", { cwd: "/school/physics", threadRef, }, ); + const { container } = screen; const [notes, other] = container.querySelectorAll("a"); await act(async () => notes!.click()); expect(workspaceSearch).toHaveBeenCalledOnce(); @@ -848,185 +294,14 @@ describe("ChatMarkdown workspace file opens", () => { }); }); -describe("ChatMarkdown file citations", () => { - it.each([true, false])( - "renders Codex file citations as file chips with parseRawHtml=%s", - async (parseRawHtml) => { - const container = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx" purpose="output"}.', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - expect(container.textContent).not.toContain("codex-file-citation"); - expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); - }, - ); - - it("leaves an unfinished streaming citation visible until it is complete", async () => { - const container = await renderText( - 'Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"', - { isStreaming: true }, - ); - expect(container.textContent).toContain(":codex-file-citation"); - expect(fileOptions(container)).toEqual([]); - }); - - it.each([ - ':codex-file-citation{purpose="output"}', - ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', - 'Example: \\:codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', - ])("leaves %s literal", async (text) => { - const container = await renderText(text); - expect(container.textContent).toContain(text.replace("\\", "")); - expect(fileOptions(container)).toEqual([]); - }); - - it("preserves Codex file citation examples inside code", async () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const container = await renderText( - `Example: \`${directive}\`\n\n\`\`\`text\n${directive}\n\`\`\``, - ); - expect(container.textContent?.match(/:codex-file-citation/g)).toHaveLength(2); - expect(fileOptions(container)).toEqual([]); - }); - - it("does not create a nested link for citations inside link text", async () => { - const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; - const container = await renderText(`[See ${directive}](https://example.com)`); - expect(container.textContent).toContain("codex-file-citation"); - expect(fileOptions(container)).toEqual([]); - }); - - it("renders file citations created by over-indented list recovery", async () => { - const container = await renderText( - '- Created :codex-file-citation{path="/tmp/project/outputs/report.xlsx"}', - ); - expect(container.querySelector("pre")).toBeNull(); - expect(container.querySelector("li")?.textContent).toContain("Created "); - expect(fileOptions(container)).toEqual(["File options for report.xlsx"]); - }); - - it("disambiguates Codex citations with the same basename", async () => { - const container = await renderText( - 'Changed :codex-file-citation{path="/tmp/project/src/index.ts"} and :codex-file-citation{path="/tmp/project/test/index.ts"}.', - ); - expect(container.textContent).toContain("index.ts · project/src"); - expect(container.textContent).toContain("index.ts · project/test"); - }); - - it("preserves rejected citations created by over-indented list recovery", async () => { - const malformed = await renderText( - 'Leading text before list.\n\n- Bad :codex-file-citation{purpose="output"}', - ); - expect(malformed.querySelector("li")?.textContent).toBe( - 'Bad :codex-file-citation{purpose="output"}', - ); - const nestedLink = await renderText( - 'Leading text before list.\n\n- [Bad :codex-file-citation{path="/tmp/project/report.xlsx"}](https://example.com)', - ); - expect(nestedLink.textContent).toContain( - 'Bad :codex-file-citation{path="/tmp/project/report.xlsx"}', - ); - }); -}); - -describe("ChatMarkdown Codex follow-up suggestions", () => { - it.each([true, false])("renders the label with parseRawHtml=%s", async (parseRawHtml) => { - const container = await renderText( - 'Done.\n\n- :codex-followup[Prepare print version]{prompt="Prepare the document for printing."}', - { lineBreaks: !parseRawHtml, parseRawHtml }, - ); - expect(container.textContent).toContain("Prepare print version"); - expect(container.textContent).not.toContain("codex-followup"); - expect(container.textContent).not.toContain("Prepare the document for printing."); - }); - - it("keeps the label of a malformed follow-up created by over-indented list recovery", async () => { - const container = await renderText("- Next :codex-followup[Prepare print version]"); - expect(container.querySelector("li")?.textContent).toBe( - "Next :codex-followup[Prepare print version]", - ); - }); -}); - -const ARTIFACT_TEMPLATE_DIRECTIVE = - '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; - -describe("ChatMarkdown artifact-template cards", () => { - it.each([true, false])( - "offers the Codex template from its card with parseRawHtml=%s", - async (parseRawHtml) => { - const onUseArtifactTemplate = vi.fn(); - const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE, { - parseRawHtml, - onUseArtifactTemplate, - }); - expect(container.textContent).not.toContain("::artifact-template"); - expect(container.textContent).toContain("Hello World"); - expect(container.textContent).toContain("Document template"); - expect(container.querySelector("p div")).toBeNull(); - const use = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Use template", - )!; - await act(async () => use.click()); - expect(onUseArtifactTemplate).toHaveBeenCalledWith( - expect.objectContaining({ - skillName: "artifact-template-hello-world", - displayName: "Hello World", - }), - ); - }, - ); - - it("renders a passive card outside a composer-backed timeline", async () => { - const container = await renderText(ARTIFACT_TEMPLATE_DIRECTIVE); - expect(container.textContent).toContain("Hello World"); - expect(container.textContent).not.toContain("Use template"); - }); - - it.each([ - '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}', - ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1), - `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, - ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), - ])("leaves %s literal", async (text) => { - const container = await renderText(text); - expect(container.textContent).toContain("::artifact-template"); - expect(container.textContent).not.toContain("Document template"); - }); - - it("preserves artifact-template examples inside code", async () => { - const container = await renderText( - `\`${ARTIFACT_TEMPLATE_DIRECTIVE}\`\n\n\`\`\`text\n${ARTIFACT_TEMPLATE_DIRECTIVE}\n\`\`\``, - ); - expect(container.textContent?.match(/::artifact-template/g)).toHaveLength(2); - expect(container.textContent).not.toContain("Document template"); - }); -}); - describe("ChatMarkdown headings", () => { - it("exposes headings below the host heading without changing their tags", async () => { - const container = await renderText("# Top\n\n## Section\n\n###### Fine print", { - headingLevelOffset: 3, - }); - expect( - [...container.querySelectorAll("h1, h2, h6")].map((heading) => [ - heading.tagName, - heading.getAttribute("aria-level"), - ]), - ).toEqual([ - ["H1", "4"], - ["H2", "5"], - ["H6", "6"], - ]); - const flat = await renderText("# Top"); - expect(flat.querySelector("h1")?.hasAttribute("aria-level")).toBe(false); - }); - it("never gives two headings the same id, even when a suffix matches another heading", async () => { - const container = await renderText( + setup(); + await using screen = await renderText( '## Setup\n\n## Setup\n\n## Setup-1\n\n

Pinned

\n\n## Install\n\n## Install', { parseRawHtml: true }, ); + const { container } = screen; expect([...container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ "user-content-setup", "user-content-setup-1", @@ -1052,468 +327,17 @@ describe("ChatMarkdown Windows file links", () => { chip.getAttribute("href"), ); - it.each([ - ["drive paths", "[Open](C:/Users/shawn/project/src/main.ts)"], - ["backslashes", String.raw`[Open](C:\Users\shawn\project\src\main.ts)`], - ["reference links", "[Open][source]\n\n[source]: C:/Users/shawn/project/src/main.ts"], - ])("resolves %s to the workspace file", async (_, text) => { - for (const parseRawHtml of [true, false]) { - const container = await renderWindows(text, parseRawHtml); - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/project/src/main.ts"]); - } - }); - - it("keeps backslashes CommonMark would read as escapes", async () => { - for (const parseRawHtml of [true, false]) { - const container = await renderWindows( - String.raw`[settings](C:\Users\shawn\.claude\settings.json)`, - parseRawHtml, - ); - expect(fileHrefs(container)).toEqual(["C:/Users/shawn/.claude/settings.json"]); - } - }); - - it("distinguishes same-named backslash paths but not the same file twice", async () => { - for (const parseRawHtml of [true, false]) { - const distinct = await renderWindows( - String.raw`[Source](C:\Users\shawn\project\src\index.ts) and [Test](C:\Users\shawn\project\test\index.ts)`, - parseRawHtml, - ); - expect(distinct.textContent).toContain("index.ts · project/src"); - expect(distinct.textContent).toContain("index.ts · project/test"); - const path = String.raw`C:\Users\shawn\project\src\main.ts`; - const same = await renderWindows(`[Source](${path}) and \`${path}\``, parseRawHtml); - expect(fileHrefs(same)).toHaveLength(2); - expect(same.textContent).not.toContain("main.ts ·"); - } - }); - it("still rejects unsafe schemes", async () => { + setup(); for (const parseRawHtml of [true, false]) { - const container = await renderWindows( + await using screen = await renderWindows( "[unsafe](javascript:alert(1)) and [unknown](d:alert(1))", parseRawHtml, ); + const { container } = screen; expect(container.innerHTML).not.toContain("javascript:"); expect(container.innerHTML).not.toContain("d:alert"); expect(fileHrefs(container)).toEqual([]); } }); }); - -describe("ChatMarkdown in-page links", () => { - it.each([true, false])( - "scrolls a table-of-contents link to its heading without touching the URL (parseRawHtml=%s)", - async (parseRawHtml) => { - const scrollIntoView = vi - .spyOn(HTMLElement.prototype, "scrollIntoView") - .mockImplementation(() => {}); - const href = window.location.href; - const container = await renderText( - "- [Operating model](#1-operating-model)\n- [Missing](#nowhere)\n\n## 1. Operating model\n\n## 1. Operating model", - { parseRawHtml }, - ); - const headings = [...container.querySelectorAll("h2")]; - expect(headings.map((heading) => heading.id)).toEqual([ - "user-content-1-operating-model", - "user-content-1-operating-model-1", - ]); - const [tocLink, missingLink] = [...container.querySelectorAll("a")]; - await act(async () => tocLink!.click()); - expect(scrollIntoView.mock.contexts).toEqual([headings[0]]); - await act(async () => missingLink!.click()); - expect(scrollIntoView).toHaveBeenCalledTimes(1); - expect(window.location.href).toBe(href); - }, - ); -}); - -describe("ChatMarkdown thread find", () => { - function FindProbe(props: { - container: HTMLElement; - query: string; - activeOccurrence?: number; - children: ReactElement; - }) { - useThreadFindHighlights({ - container: props.container, - query: props.query, - activeRowId: "row", - activeOccurrence: props.activeOccurrence ?? 0, - onActiveRange: () => {}, - }); - return ( -
-
- {props.children} -
-
- ); - } - const frame = () => - act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - const activeHighlights = () => - [...(CSS.highlights.get("t3-thread-find-active") ?? [])].map((range) => - (range as Range).toString(), - ); - - it("opens a disclosure only when find selects a match inside it", async () => { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - const markdown = ( - Unrelated

nothing here

", - "
Outer
Inner

needle

", - ].join("\n\n")} - /> - ); - const show = (query: string, activeOccurrence: number) => - act(() => - root.render( - - {markdown} - , - ), - ); - const openStates = () => - [...container.querySelectorAll("[data-markdown-details-open]")].map( - (node) => node.dataset.markdownDetailsOpen, - ); - - await show("", 0); - expect(container.textContent).not.toContain("nothing here"); - await show("needle", 0); - await frame(); - expect(openStates()).toEqual(["false", "false", "false"]); - expect(container.textContent).toContain("nothing here"); - expect(activeHighlights()).toEqual(["needle"]); - expect(CSS.highlights.get("t3-thread-find")?.size ?? 0).toBe(0); - - await show("needle", 1); - await frame(); - await frame(); - expect(openStates()).toEqual(["false", "true", "true"]); - expect(CSS.highlights.get("t3-thread-find-active")?.size).toBe(1); - }); - - it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => { - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```"; - const show = (query: string) => - act(() => - root.render( - - - , - ), - ); - const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; - - await show("Needle"); - await frame(); - expect(diagrams()).toBe(2); - await show("Alpha"); - await frame(); - await frame(); - expect(diagrams()).toBe(1); - await vi.waitFor(() => expect(activeHighlights()).toEqual(["Alpha"])); - }); - - it.each([ - { - text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```", - query: "SearchSourceAlpha", - count: 1, - }, - { - text: "★ Insight ─────\nfirst line\nsecond line", - query: "first line second", - count: 0, - lineBreaks: true, - }, - { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 }, - { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 }, - { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 }, - { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true }, - { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 }, - { - text: "
Folded

Hidden needle

", - query: "Hidden needle", - count: 1, - }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, withTemplate: true }, - { - text: ARTIFACT_TEMPLATE_DIRECTIVE, - query: "Document template", - count: 1, - withTemplate: true, - }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, withTemplate: true }, - { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, withTemplate: true }, - ])( - "highlights the indexed occurrences of $query in $text", - async ({ text, query, count, lineBreaks, user, withTemplate }) => { - const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; - const container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(() => - root.render( - - undefined : undefined} - /> - , - ), - ); - await frame(); - // Lazy blocks such as Mermaid can swap their source text after the first frame, - // emptying a range for a moment, so wait for the highlights to settle. - await vi.waitFor(() => { - const ranges = [...CSS.highlights.values()].flatMap((value) => [...value]); - expect(ranges.map((range) => (range as Range).toString())).toEqual( - Array.from({ length: count }, () => query), - ); - }); - const segments = - searchableMessageSegments( - { role: user ? "user" : "assistant", text, streaming: false }, - undefined, - skills, - ) ?? []; - expect( - segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0), - ).toBe(count); - }, - ); -}); - -it.each([ - { - name: "Windows drive paths on a Mac client", - platform: "MacIntel", - cwd: "C:/repo", - href: "C:/repo/reports/Device Users.csv:12:3", - full: String.raw`C:\repo\reports\Device Users.csv:12:3`, - relative: String.raw`reports\Device Users.csv:12:3`, - }, - { - name: "UNC paths on a Mac client", - platform: "MacIntel", - cwd: String.raw`\\server\share\repo`, - href: "file://server/share/repo/reports/report.csv", - full: String.raw`\\server\share\repo\reports\report.csv`, - relative: String.raw`reports\report.csv`, - }, - { - name: "POSIX paths on a Windows client", - platform: "Win32", - cwd: "/home/repo", - href: "/home/repo/reports/report.csv", - full: "/home/repo/reports/report.csv", - relative: "reports/report.csv", - }, -])("copies $name and shows the copied text", async ({ platform, cwd, href, full, relative }) => { - vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); - vi.stubGlobal("ClipboardItem", undefined); - const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); - const toast = vi.spyOn(toastManager, "add"); - const api = readLocalApi(); - if (!api) throw new Error("Local API unavailable"); - const choose = vi.spyOn(api.contextMenu, "show"); - const container = await renderText(`[File](<${href}>)`, { - cwd, - environmentId: EnvironmentId.make("env-file-copy"), - }); - const link = container.querySelector(".chat-markdown-file-link"); - if (!link) throw new Error("File link unavailable"); - for (const [action, expected] of [ - ["copy-full", full], - ["copy-relative", relative], - ] as const) { - choose.mockResolvedValue(action); - await act(async () => { - link.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); - }); - expect(writeText).toHaveBeenLastCalledWith(expected); - expect(toast).toHaveBeenLastCalledWith( - expect.objectContaining({ type: "success", description: expected }), - ); - } -}); - -describe("ChatMarkdown math", () => { - const renderMath = async ( - text: string, - options: Partial[0]> = {}, - ) => { - settingsOverrides.mathRenderingEnabled = true; - const { container: host } = await render( - , - ); - host.setAttribute("data-assistant-citation-source", "assistant"); - await act(async () => { - await import("./chat/MathTypeset"); - }); - return { host }; - }; - - it.each(["$x$", "$$x$$", "```math\nx\n```", "$\\notACommand{x}$"])( - "keeps keyboard focus on the parent control in compact previews of %s", - async (formula) => { - settingsOverrides.mathRenderingEnabled = true; - const { container } = await render( - <> - - - , - ); - await act(async () => { - await import("./chat/MathTypeset"); - }); - const [preview, after] = container.querySelectorAll("button"); - preview!.focus(); - await act(() => userEvent.tab()); - expect(document.activeElement).toBe(after); - if (formula.includes("notACommand")) { - expect(page.getByRole("button", { name: /notACommand/ }).element()).toBe(preview); - } - }, - ); - - it("keeps message formulas reachable by keyboard", async () => { - settingsOverrides.mathRenderingEnabled = true; - const { container } = await render( - <> - - - , - ); - container.querySelector("button")!.focus(); - await act(() => userEvent.tab()); - expect(document.activeElement).toBe( - container.querySelector('[aria-label="Scroll inline equation"]'), - ); - }); - - it("renders table formulas and exports their TeX instead of duplicate glyph text", async () => { - const rendered = await renderMath( - "| Name | Formula |\n| --- | --- |\n| Norm | $|x|$ and \\(\\|y\\|\\) |\n| Price | $5 and $10 |", - ); - - const table = rendered.host.querySelector("table")!; - expect([...table.rows].map((row) => row.cells.length)).toEqual([2, 2, 2]); - expect(table.querySelectorAll(".katex")).toHaveLength(2); - const norm = table.querySelectorAll(".katex")[1]!; - expect([...norm.querySelectorAll(".katex-mathml mi")].map((node) => node.textContent)).toEqual([ - "∥", - "y", - "∥", - ]); - expect(serializeTableElementToCsv(table)).toBe( - "Name,Formula\nNorm,\\(|x|\\) and \\(\\|y\\|\\)\nPrice,$5 and $10", - ); - const glyph = table.querySelector(".katex-mathml mi")!.firstChild!; - const range = document.createRange(); - range.setStart(glyph, 0); - range.setEnd(glyph, 1); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - const payload = chatMarkdownClipboardPayload(selection)!; - expect(payload.text).toBe("\\(|x|\\)"); - expect(payload.html).toContain("\\(|x|\\)"); - expect(payload.html).not.toContain("katex"); - expect(captureAssistantTextSelection(rendered.host, selection)?.selector.text).toBe( - "\\(|x|\\)", - ); - }); - - it("renders explicit math fences and keeps unfinished streamed fences readable", async () => { - const rendered = await renderMath("```math\nx_i^2\n```\n\n`$literal$`\n\n$unfinished"); - - expect(rendered.host.querySelectorAll(".katex")).toHaveLength(1); - expect(rendered.host.textContent).toContain("$literal$"); - expect(rendered.host.textContent).toContain("$unfinished"); - - const streamed = await renderMath("```math\nx_i^2", { isStreaming: true }); - - expect(streamed.host.querySelector(".katex")).toBeNull(); - expect(streamed.host.textContent).toContain("x_i^2"); - }); - - it.each(["$$\nx^2\n", "\\[\nx^2\n"])( - "keeps unfinished display %s and following prose readable while streaming", - async (source) => { - for (const text of [source, `${source}\nFollowing prose.`]) { - const { host } = await renderMath(text, { isStreaming: true }); - expect(host.querySelector(".katex")).toBeNull(); - expect(host.querySelector("[data-markdown-math]")).toBeNull(); - expect(host.textContent).toContain("x^2"); - if (text.includes("Following prose.")) - expect(host.textContent).toContain("Following prose."); - } - }, - ); - - it.each(["$$x^2$$", "$$\nx^2\n$$", "\\[\nx^2\n\\]", "```math\nx^2\n```"])( - "renders completed %s while the response is streaming", - async (source) => { - const { host } = await renderMath(source, { isStreaming: true }); - expect(host.querySelectorAll(".katex")).toHaveLength(1); - expect(host.querySelector('[data-markdown-math="display"]')).not.toBeNull(); - }, - ); - - it("shows unsupported TeX as source and keeps active TeX commands inert", async () => { - const rendered = await renderMath( - "\\(\\notACommand{x}\\) and \\(\\href{https://example.com}{x}\\)", - ); - - expect(rendered.host.textContent).toContain("\\(\\notACommand{x}\\)"); - expect(rendered.host.querySelector('a[href="https://example.com"]')).toBeNull(); - }); - - it("typesets only when enabled, keeping prices and task offsets intact", async () => { - const text = "Area $\\pi r^2$ costs $5 and $10.\n\n\\[\nE = mc^2\n\\]\n\n- [ ] check \\(x\\)"; - const off = await renderText(text); - expect(off.querySelector("[data-markdown-math]")).toBeNull(); - const html = await renderText('x'); - expect(html.querySelector("[data-markdown-math]")).toBeNull(); - for (const parseRawHtml of [true, false]) { - let editedText = text; - const { host } = await renderMath(text, { - parseRawHtml, - onTaskListChange: ({ markerOffset, checked }) => { - editedText = setMarkdownTaskChecked(text, markerOffset, checked); - }, - }); - expect( - [...host.querySelectorAll("[data-markdown-math]")].map((node) => - node.getAttribute("data-markdown-copy"), - ), - ).toEqual(["\\(\\pi r^2\\)", "\\[\nE = mc^2\n\\]\n\n", "\\(x\\)"]); - await act(() => host.querySelector('input[type="checkbox"]')!.click()); - expect(editedText).toBe(text.replace("- [ ]", "- [x]")); - } - const { host } = await renderMath("- intro\n\n $$\n x^2\n $$"); - expect( - [...host.querySelectorAll("[data-markdown-math]")].map((node) => - node.getAttribute("data-markdown-copy"), - ), - ).toEqual(["\\[\nx^2\n\\]\n\n"]); - }); -}); diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts deleted file mode 100644 index 0e8a55d9fb6b..000000000000 --- a/apps/web/src/components/ChatMarkdown.logic.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { resolveMarkdownFileLinkMeta } from "../markdown-links"; - -import { - canUseMarkdownFileShellActions, - markdownFileCopyMenuItems, - shouldUseMarkdownFileBrowserPrimaryAction, -} from "./ChatMarkdown"; - -describe("canUseMarkdownFileShellActions", () => { - const environmentId = EnvironmentId.make("environment-1"); - - it("allows editor and file manager actions only for resolved local environments", () => { - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); - expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); - expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); - expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); - expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); - }); -}); - -describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { - const html = { iconPath: "/tmp/report.html", canOpenInBrowser: true }; - - it("uses the browser only when it is the sole action, except for PDFs", () => { - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: false, - canOpenInPanel: false, - }), - ).toBe(true); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: true, - canOpenInPanel: false, - }), - ).toBe(false); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - ...html, - canOpenInEditor: false, - canOpenInPanel: true, - }), - ).toBe(false); - expect( - shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath: "/tmp/report.pdf", - canOpenInEditor: true, - canOpenInBrowser: true, - canOpenInPanel: true, - }), - ).toBe(true); - }); -}); - -describe("markdownFileCopyMenuItems", () => { - it.each([ - { - name: "project file", - href: "/Users/jordan/project/src/main.ts", - cwd: "/Users/jordan/project", - relative: "src/main.ts", - full: "/Users/jordan/project/src/main.ts", - }, - { - name: "worktree file with a line and column", - href: "src/main.ts:12:3", - cwd: "/Users/jordan/project/.claude/worktrees/topic", - relative: "src/main.ts:12:3", - full: "/Users/jordan/project/.claude/worktrees/topic/src/main.ts:12:3", - }, - { - name: "main checkout file outside the thread worktree", - href: "/Users/jordan/project/src/main.ts", - cwd: "/Users/jordan/project/.claude/worktrees/topic", - relative: null, - full: "/Users/jordan/project/src/main.ts", - }, - ])("offers accurate clipboard values for $name", ({ href, cwd, relative, full }) => { - const meta = resolveMarkdownFileLinkMeta(href, cwd); - expect(meta).not.toBeNull(); - if (!meta) throw new Error(`Expected a file link for ${href}`); - - expect(markdownFileCopyMenuItems(meta)).toEqual([ - ...(relative === null - ? [] - : [ - { - id: "copy-relative", - label: "Copy relative path", - value: relative, - title: "Relative path", - }, - ]), - { - id: "copy-full", - label: "Copy full path", - value: full, - title: "Full path", - }, - ]); - }); -}); diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index b081d0f0f8f1..de274fdd433b 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -8,10 +8,9 @@ import { type ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; +import { act, render } from "../../test/render"; import { useRightPanelStore } from "../rightPanelStore"; const state = vi.hoisted(() => ({ @@ -130,10 +129,9 @@ const serverConfig = { shellRevealInFileManagerKind: "xdg-open", environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, }; -const roots: Root[] = []; -let container: HTMLDivElement; -beforeEach(() => { +function setup() { + using stack = new DisposableStack(); state.allowed = true; state.listeners.clear(); state.linkedPullRequest = null; @@ -144,40 +142,35 @@ beforeEach(() => { state.openFile.mockReset(); state.openExternal.mockReset().mockResolvedValue(undefined); state.copy.mockReset().mockResolvedValue(undefined); + const { openFile } = useRightPanelStore.getState(); useRightPanelStore.setState({ openFile: state.openFile }); - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy); - vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { - const item = items[0]!; - await state.copy(await (await item.getType("text/plain")).text()); - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function renderMarkdown(text: string) { - container = document.body.appendChild(document.createElement("div")); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render(), + stack.defer(() => useRightPanelStore.setState({ openFile })); + stack.use(vi.spyOn(navigator.clipboard, "writeText").mockImplementation(state.copy)); + stack.use( + vi.spyOn(navigator.clipboard, "write").mockImplementation(async (items) => { + const item = items[0]!; + await state.copy(await (await item.getType("text/plain")).text()); + }), ); + return stack.move(); } -function anchor() { - return container.querySelector("a")!; -} - -async function openContextMenu() { - await act(async () => { - anchor().dispatchEvent( - new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }), - ); +async function renderMarkdown(text: string) { + const screen = await render(); + const anchor = () => screen.container.querySelector("a")!; + return Object.assign(screen, { + anchor, + openContextMenu: () => + act(async () => { + anchor().dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 10, + clientY: 10, + }), + ); + }), }); } @@ -193,40 +186,22 @@ async function setAllowed(allowed: boolean) { } it("removes host actions after revocation while keeping file preview and copying", async () => { - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); - expect(offeredActions()).toEqual(["open", "open-with", "reveal", "copy-relative", "copy-full"]); + using _setup = setup(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); + expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); - await openContextMenu(); + await screen.openContextMenu(); expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md")); - await act(async () => anchor().click()); + await act(async () => screen.anchor().click()); expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); expect(state.openEditor).not.toHaveBeenCalled(); }); -it("starts copying before the nested-file lookup finishes", async () => { - let finishLookup: () => void = () => { - throw new Error("Lookup not started"); - }; - state.search.mockImplementationOnce( - () => - new Promise((resolve) => { - finishLookup = () => - resolve(AsyncResult.success({ entries: [{ path: "src/readme.md", kind: "file" }] })); - }), - ); - state.choose.mockResolvedValue("copy-full"); - await renderMarkdown("`readme.md:10:2`"); - await openContextMenu(); - expect(navigator.clipboard.write).toHaveBeenCalledOnce(); - expect(state.copy).not.toHaveBeenCalled(); - await act(async () => finishLookup()); - await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/src/readme.md:10:2")); -}); - it("does not launch an editor after revocation during a native context menu", async () => { + using _setup = setup(); let choose: (action: string) => void = () => { throw new Error("Menu not opened"); }; @@ -236,8 +211,8 @@ it("does not launch an editor after revocation during a native context menu", as choose = resolve; }), ); - await renderMarkdown("[Readme](docs/readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](docs/readme.md)"); + await screen.openContextMenu(); expect(offeredActions()).toContain("open"); await act(async () => { state.allowed = false; @@ -247,6 +222,7 @@ it("does not launch an editor after revocation during a native context menu", as }); it("does not reveal a file after revocation during its workspace lookup", async () => { + using _setup = setup(); let finishLookup: () => void = () => { throw new Error("Lookup not started"); }; @@ -257,8 +233,8 @@ it("does not reveal a file after revocation during its workspace lookup", async }), ); state.choose.mockResolvedValue("reveal"); - await renderMarkdown("[Readme](readme.md)"); - await openContextMenu(); + await using screen = await renderMarkdown("[Readme](readme.md)"); + await screen.openContextMenu(); expect(state.search).toHaveBeenCalledOnce(); await act(async () => { state.allowed = false; @@ -267,23 +243,10 @@ it("does not reveal a file after revocation during its workspace lookup", async expect(state.openEditor).not.toHaveBeenCalled(); }); -it.each([false, true])( - "keeps PR links usable without offering metadata edits (linked=%s)", - async (linked) => { - state.allowed = false; - state.linkedPullRequest = linked ? linkedPullRequest : null; - state.choose.mockResolvedValue("open-external"); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); - expect(offeredActions()).toEqual(["open-external", "copy-link"]); - await vi.waitFor(() => expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url)); - expect(state.updateMetadata).not.toHaveBeenCalled(); - }, -); - it.each(["link-to-thread", "unlink-from-thread"])( "rechecks %s after a native context menu returns", async (action) => { + using _setup = setup(); state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null; let choose: (action: string) => void = () => { throw new Error("Menu not opened"); @@ -294,8 +257,8 @@ it.each(["link-to-thread", "unlink-from-thread"])( choose = resolve; }), ); - await renderMarkdown(`[Review](${linkedPullRequest.url})`); - await openContextMenu(); + await using screen = await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await screen.openContextMenu(); expect(offeredActions()).toContain(action); await act(async () => { state.allowed = false; @@ -305,7 +268,7 @@ it.each(["link-to-thread", "unlink-from-thread"])( await setAllowed(true); state.choose.mockResolvedValue(action); - await openContextMenu(); + await screen.openContextMenu(); await vi.waitFor(() => expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ environmentId: threadRef.environmentId, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 55e21dfda03a..9cd64e07daf7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,16 +1,4 @@ -import { - resolveFileOpenTarget, - DEFAULT_SERVER_SETTINGS, - EDITORS, - type GitHubRefType, -} from "@t3tools/contracts"; -import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; -import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext"; -import { - buildFileLinkParentSuffixByPath, - fileLinkLabel, - splitFilePathPosition, -} from "@t3tools/shared/fileLinks"; +import { buildFileLinkParentSuffixByPath, fileLinkLabel } from "@t3tools/shared/fileLinks"; import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination, @@ -32,8 +20,6 @@ import { encodeComposerContextClipboardHtml, } from "@t3tools/shared/composerContextClipboard"; import { - ChevronRightIcon, - CodeIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, @@ -44,31 +30,25 @@ import { MessageSquareIcon, MessageSquareWarningIcon, OctagonAlertIcon, - PlayIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, - WorkflowIcon, - WrapTextIcon, type LucideIcon, } from "lucide-react"; -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; import { AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, - type AssetResource, type EditorId, type EnvironmentId, type ServerConfig, type ScopedThreadRef, + type ServerProviderSkill, type ThreadPullRequestKey, } from "@t3tools/contracts"; -import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; import { isAtomCommandInterrupted, squashAtomCommandFailure, - type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, @@ -79,22 +59,16 @@ import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import React, { - Children, Suspense, - type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - isValidElement, use, memo, useEffect, @@ -109,7 +83,6 @@ import type { Options as ReactMarkdownOptions, } from "react-markdown"; import ReactMarkdown from "react-markdown"; -import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; @@ -123,20 +96,16 @@ import { artifactTemplateFromHastProperties, renderCodexInlineDirectivesAsMarkdown, } from "@t3tools/shared/codexMarkdownDirectives"; -import { renderSkillInlineMarkdownChildren, type MessageSkill } from "./chat/SkillInlineText"; +import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { MarkdownMath } from "./chat/MarkdownMath"; import { resolveMarkdownMediaPreview, type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; -import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; -import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; -import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { markdownImageGallery } from "./chat/markdownImageGallery"; +import { type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; -import { ContextPreviewHover } from "./contextPresentationRegistry"; -import { FileTagChipContent } from "./chat/FileTagChip"; -import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, @@ -145,14 +114,8 @@ import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; -import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; -import { MorphIcon } from "~/components/MorphIcon"; -import { ContextChip } from "./ContextChip"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; -import { ScrollArea } from "./ui/scroll-area"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { @@ -161,28 +124,15 @@ import { usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; -import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; -import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; -import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; +import { resolveDiffThemeName } from "../lib/diffRendering"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; -import { MermaidDiagram } from "./chat/MermaidDiagram"; import { useRenderedAppearance } from "../hooks/useTheme"; -import { getClientSettings, useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; -import { - chatMarkdownClipboardPayload, - serializeTableElementToCsv, - serializeTableElementToMarkdown, -} from "../markdown-clipboard"; +import { useClientSettings } from "../hooks/useSettings"; +import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, - shouldOpenMarkdownFileLinkInBrowserByDefault, - shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { @@ -193,19 +143,11 @@ import { import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; -import { - resolveRemoteOpenState, - useRemoteOpenResolution, - type RemoteOpenMode, -} from "../remoteOpen"; +import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProject, useProjects, useQuietThreadShell } from "../state/entities"; import { serverEnvironment } from "../state/server"; -import { slackEnvironment } from "../state/slack"; -import { discordEnvironment } from "../state/discord"; -import { granolaEnvironment } from "../state/granola"; import { linearEnvironment } from "../state/linear"; import { remarkLinearIssueReferences } from "../lib/linearMarkdown"; import { remarkGitHubIssueReferences } from "../lib/githubIssueMarkdown"; @@ -217,14 +159,12 @@ import { type LinearIssueReferences, mayHaveLinearIssueReference, } from "@t3tools/shared/linearIssueReferences"; -import { useEnvironmentQuery } from "../state/query"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { readEnvironmentScope, readPreparedConnection, useEnvironmentScope, - usePreparedConnection, } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; @@ -238,15 +178,13 @@ import { pickWorkspaceBasenameMatch, stripRepeatedWorkspacePrefix, WORKSPACE_BASENAME_LOOKUP_LIMIT, -} from "../workspaceBasenameLookup"; +} from "@t3tools/client-runtime/workspace-basename-lookup"; import { parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, resolvePullRequestPreviewTarget, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; -import { objectLinkLabel, parseComposerObjectLink } from "@t3tools/shared/composerObjectLinks"; -import { observeResize } from "~/lib/observeResize"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; @@ -259,8 +197,41 @@ import { } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; -import { isPreviewableObjectLink, LinkedObjectLinkPreview } from "./chat/LinkedObjectLinkPreview"; -import { GitHubRefIcon, gitHubRefTypeFromUrl } from "./gitHubRefPresentation"; +import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; +import { MarkdownTable } from "./chat/MarkdownTable"; +import { MarkdownDetails } from "./chat/MarkdownDetails"; +import { + authoredImageSizeStyle, + ChatMarkdownAssetImage, + ChatMarkdownImage, + ChatMarkdownImageFallback, + ChatMarkdownVideo, + MarkdownLinkContext, + markdownImageCopy, +} from "./chat/ChatMarkdownMedia"; +import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; +import { LinkedObjectLinkPreview, isPreviewableObjectLink } from "./chat/LinkedObjectLinkPreview"; +import { gitHubRefTypeFromUrl } from "./gitHubRefPresentation"; +import { objectLinkLabel, parseComposerObjectLink } from "@t3tools/shared/composerObjectLinks"; +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useEnvironmentQuery } from "../state/query"; +import { slackEnvironment } from "../state/slack"; +import { discordEnvironment } from "../state/discord"; +import { granolaEnvironment } from "../state/granola"; +import { MarkdownFileLink } from "./chat/MarkdownFileLink"; +import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; +import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; +import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; +import { + extractCodeBlock, + extractFenceLanguage, + extractFenceTitle, + extractPreCodeMeta, + isClosedCodeFence, + nodeToPlainText, +} from "./chat/MarkdownCodeBlock.logic"; +import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); @@ -274,7 +245,7 @@ interface ChatMarkdownProps { environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; - skills?: ReadonlyArray; + skills?: ReadonlyArray>; className?: string; lineBreaks?: boolean; parseRawHtml?: boolean; @@ -297,61 +268,7 @@ export interface ChatMarkdownContextReference { label: string; } -export function canUseMarkdownFileShellActions( - environmentId: EnvironmentId | null, - remoteOpenMode: RemoteOpenMode, - isRemoteOpenResolved: boolean, -): boolean { - return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; -} - -function hasMarkdownFilePrimaryAction(input: { - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; - canOpenMedia?: boolean; -}): boolean { - return ( - input.canOpenInEditor || - input.canOpenInBrowser || - input.canOpenInPanel || - input.canOpenMedia === true - ); -} - -export function markdownFileCopyMenuItems({ - targetPath, - workspaceRelativePath, -}: Pick) { - const items = [ - { id: "copy-full", label: "Copy full path", value: targetPath, title: "Full path" }, - ]; - if (workspaceRelativePath !== null) { - const position = targetPath.slice(splitFilePathPosition(targetPath).path.length); - items.unshift({ - id: "copy-relative", - label: "Copy relative path", - value: `${workspaceRelativePath}${position}`, - title: "Relative path", - }); - } - return items; -} - -export function shouldUseMarkdownFileBrowserPrimaryAction(input: { - iconPath: string; - canOpenInEditor: boolean; - canOpenInBrowser: boolean; - canOpenInPanel: boolean; -}): boolean { - return ( - input.canOpenInBrowser && - (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || - (!input.canOpenInEditor && !input.canOpenInPanel)) - ); -} - -const EMPTY_MARKDOWN_SKILLS: ReadonlyArray = []; +const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; const NO_LINEAR_ISSUE_REFERENCES_ATOM = Atom.make(null).pipe( Atom.withLabel("chat-markdown:no-linear-issue-references"), @@ -415,2158 +332,87 @@ function CodexArtifactTemplateCard(props: { ) : null}
); -} - -const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; -const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; -const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; -const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; - -interface MarkdownActionFailureContext { - readonly operation: string; - readonly target?: string; - readonly format?: "markdown" | "csv"; - readonly language?: string; - readonly fenceTitle?: string; - readonly copyTarget?: string; -} - -function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { - console.error("[chat-markdown] action failed", context, cause); -} - -const highlightedCodeCache = new LRUCache( - MAX_HIGHLIGHT_CACHE_ENTRIES, - MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, -); - -function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { - const firstLineEnd = markdown.indexOf("\n", listItemStart); - const firstLine = markdown.slice( - listItemStart, - firstLineEnd === -1 ? markdown.length : firstLineEnd, - ); - const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); - if (!match?.[1]) return null; - return listItemStart + firstLine.indexOf(match[1]); -} - -function orderedListGutterStyle( - itemCount: number, - start: unknown, -): { "--list-gutter": string } | undefined { - const parsedStart = Number.parseInt(String(start ?? 1), 10); - const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; - const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); - if (markerWidth <= 1) return undefined; - return { "--list-gutter": `${markerWidth + 2}ch` }; -} - -const GITHUB_ALERT_PRESENTATIONS: Record< - string, - { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } -> = { - note: { - label: "Note", - Icon: InfoIcon, - borderClassName: "border-blue-500/70", - titleClassName: "text-blue-600 dark:text-blue-400", - }, - tip: { - label: "Tip", - Icon: LightbulbIcon, - borderClassName: "border-emerald-500/70", - titleClassName: "text-emerald-600 dark:text-emerald-400", - }, - important: { - label: "Important", - Icon: MessageSquareWarningIcon, - borderClassName: "border-purple-500/70", - titleClassName: "text-purple-600 dark:text-purple-400", - }, - warning: { - label: "Warning", - Icon: TriangleAlertIcon, - borderClassName: "border-amber-500/70", - titleClassName: "text-amber-600 dark:text-amber-500", - }, - caution: { - label: "Caution", - Icon: OctagonAlertIcon, - borderClassName: "border-red-500/70", - titleClassName: "text-red-600 dark:text-red-400", - }, -}; - -function extractFenceLanguage(className: string | undefined): string { - const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; - // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) - return raw === "gitignore" ? "ini" : raw; -} - -const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; -const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; - -function extractFenceTitle(meta: string | undefined): string | null { - if (!meta) return null; - const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); - const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; - if (attrTitle) return attrTitle; - return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; -} - -function extractPreCodeMeta(node: unknown): string | undefined { - const children = ( - node as - | { - children?: Array<{ - type?: string; - tagName?: string; - data?: { meta?: unknown }; - properties?: { dataCodeMeta?: unknown }; - }>; - } - | undefined - )?.children; - const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); - const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; - return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; -} - -function isClosedCodeFence( - node: ReactMarkdownExtraProps["node"], - text: string, - code: string, -): boolean { - const start = node?.position?.start.offset; - const end = node?.position?.end.offset; - if (start === undefined || end === undefined) return false; - const source = text.slice(start, end); - const opening = /^(?:`{3,}|~{3,})/.exec(source)?.[0]; - // One class for the blockquote prefix: nested quantifiers here backtrack - // exponentially on code lines that start with many `> ` markers. - const closing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t\r]*$/.exec(source)?.[1]; - return ( - opening !== undefined && - closing !== undefined && - opening[0] === closing[0] && - closing.length >= opening.length && - // Only a real closer is omitted from parsed code. A CRLF stream cut after - // CR must not count as an extra source line. - source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length - ); -} - -function nodeToPlainText(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - if (Array.isArray(node)) { - return node.map((child) => nodeToPlainText(child)).join(""); - } - if (isValidElement<{ children?: ReactNode }>(node)) { - return nodeToPlainText(node.props.children); - } - return ""; -} - -function extractCodeBlock( - children: ReactNode, -): { className: string | undefined; code: string } | null { - const childNodes = Children.toArray(children); - if (childNodes.length !== 1) { - return null; - } - - const onlyChild = childNodes[0]; - if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) - ) { - return null; - } - // With a custom `code` component the child's type is that component, not - // the "code" tag — the hast node react-markdown attaches still names it. - if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { - return null; - } - - return { - className: onlyChild.props.className, - code: nodeToPlainText(onlyChild.props.children), - }; -} - -function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { - return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; -} - -function estimateHighlightedSize(html: string, code: string): number { - return Math.max(html.length * 2, code.length * 3); -} - -function readInitialWordWrapSetting(): boolean { - return getClientSettings().wordWrap; -} - -/** - * Whether any cell is clipped in the unwrapped layout. Only then does the wrap - * toggle change anything, so short tables hide it. Measures with the container - * briefly unwrapped and restores it before the browser paints. - */ -function tableHasClippedCells(container: HTMLElement, table: HTMLTableElement): boolean { - const viewport = table.closest('[data-slot="scroll-area-viewport"]'); - const scrollLeft = viewport?.scrollLeft ?? 0; - const previous = container.dataset.expanded; - container.dataset.expanded = "false"; - try { - for (const row of table.rows) { - for (const cell of row.cells) { - if (cell.scrollWidth > cell.clientWidth) return true; - } - } - return false; - } finally { - if (previous === undefined) delete container.dataset.expanded; - else container.dataset.expanded = previous; - // The unwrapped layout can be narrower and clamp a sideways scroll. - if (viewport && scrollLeft > 0) viewport.scrollLeft = scrollLeft; - } -} - -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { - const containerRef = useRef(null); - const tableRef = useRef(null); - const [expanded, setExpanded] = useState(readInitialWordWrapSetting); - const [hasClippedCells, setHasClippedCells] = useState(false); - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef | null>(null); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; - const copyLabel = copied ? "Copied" : "Copy table"; - - const measureClippedCells = () => { - const container = containerRef.current; - const table = tableRef.current; - if (!container || !table) return; - setHasClippedCells(tableHasClippedCells(container, table)); - }; - - // A streamed cell can grow until it clips while the table keeps its size, - // so remeasure after every render, not only on resize. - useLayoutEffect(measureClippedCells); - - // Column width changes can clip a cell without rendering this table. - useLayoutEffect(() => { - const table = tableRef.current; - if (!table) return; - return observeResize(table, measureClippedCells); - }, [measureClippedCells]); - - const handleCopy = (format: "markdown" | "csv") => { - const table = containerRef.current?.querySelector("table"); - if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - const text = - format === "markdown" - ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table); - void navigator.clipboard - .writeText(text) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure({ operation: "copy-table", format }, cause); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - return ( -
- - - {children} -
-
-
- {hasClippedCells ? ( - - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - - ) : ( - - )} - - - - } - /> - } - > - - - {copyLabel} - - - handleCopy("markdown")}>Copy as Markdown - handleCopy("csv")}>Copy as CSV - - -
-
- ); -} - -function MarkdownDetails({ - children, - open = false, -}: Pick, "children" | "open">) { - const [isOpen, setIsOpen] = useState(open); - const searching = use(MarkdownFindContext); - const expanded = isOpen; - const revealForFind = () => setIsOpen(true); - // Base UI only listens for `beforematch` on a panel mounted at its first - // render; a closed panel mounts later, when find starts, so listen here. - const revealRef = findRevealRef(revealForFind); - const childNodes = Children.toArray(children); - const summaryIndex = childNodes.findIndex( - (child) => isValidElement(child) && child.type === "summary", - ); - const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; - const summary = - isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children - ? summaryNode.props.children - : "Details"; - const content = childNodes.filter((_, index) => index !== summaryIndex); - - return ( -
- - - - {summary} - - -
- {content} -
-
-
-
- ); -} - -const THREAD_FIND_IGNORE_PROPS = { - "data-thread-find-ignore": "", -} as React.HTMLAttributes; - -export function MarkdownCodeBlockFrame({ - as: Wrapper = "div", - language, - fenceTitle, - theme, - wrapped = true, - title, - actions, - headerProps, - children, -}: { - as?: React.ElementType; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - wrapped?: boolean; - title?: React.ReactNode; - actions?: React.ReactNode; - headerProps?: React.HTMLAttributes; - children: React.ReactNode; -}) { - return ( - -
- {title ?? ( - - - - )} - {actions} -
- {children} -
- ); -} - -function MarkdownCodeBlockTitleContent({ - fenceTitle, - language, - theme, -}: { - fenceTitle: string | null; - language: string; - theme: "light" | "dark"; -}) { - if (fenceTitle) { - return ( - <> - - {fenceTitle} - - ); - } - - const fileName = syntheticFileNameForLanguageId(language); - if (!hasSpecificPierreIconForFileName(fileName)) { - return {language}; - } - return ( - - - } - > - - - {language} - - ); -} - -function MarkdownCodeBlock({ - code, - language, - fenceTitle, - theme, - onRunShellCommand, - isStreaming, - viewToggle, - canWrap = true, - diagram = false, - children, -}: { - code: string; - language: string; - fenceTitle: string | null; - theme: "light" | "dark"; - onRunShellCommand?: ((command: string) => void) | undefined; - isStreaming: boolean; - viewToggle?: ReactNode; - canWrap?: boolean; - diagram?: boolean; - children: ReactNode; -}) { - const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); - const copiedTimerRef = useRef | null>(null); - const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; - const copyLabel = copied ? "Copied" : "Copy code"; - const command = code.trim(); - const canRun = - onRunShellCommand !== undefined && - !isStreaming && - /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && - code.endsWith("\n") && - command.length > 0 && - !command.endsWith("\\") && - // Control and invisible format characters (bidi overrides, zero-width) can - // make the rendered command differ from what the terminal would receive. - !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); - - const handleCopy = () => { - if (typeof navigator === "undefined" || navigator.clipboard == null) { - return; - } - void navigator.clipboard - .writeText(code) - .then(() => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - } - setCopied(true); - copiedTimerRef.current = setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1200); - }) - .catch((cause) => { - reportMarkdownActionFailure( - { - operation: "copy-code-block", - language, - ...(fenceTitle ? { fenceTitle } : {}), - }, - cause, - ); - }); - }; - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - clearTimeout(copiedTimerRef.current); - copiedTimerRef.current = null; - } - }, - [], - ); - - const copyButton = ( - - - } - > - - - {copyLabel} - - ); - - if (diagram) { - return ( -
-
- {viewToggle} - {copyButton} -
- {children} -
- ); - } - - return ( - - {canWrap ? ( - - setWrapped((value) => !value)} - aria-label={wrapLabel} - /> - } - > - - - {wrapLabel} - - ) : null} - {canRun ? ( - - onRunShellCommand(command)} - aria-label="Run in terminal" - /> - } - > - - - Run in terminal - - ) : null} - {viewToggle} - {copyButton} - - } - > - {children} - - ); -} - -function MarkdownMermaidCodeBlock({ - code, - fenceTitle, - theme, - isStreaming, - onExpand, - children, -}: { - code: string; - fenceTitle: string | null; - theme: "light" | "dark"; - isStreaming: boolean; - onExpand: (imageUrl: string) => void; - children: ReactNode; -}) { - const [showCode, setShowCode] = useState(false); - const searching = use(MarkdownFindContext); - const revealSource = () => setShowCode(true); - const sourceRevealRef = findRevealRef(revealSource); - const showDiagram = !showCode && !isStreaming && code.trim().length > 0; - const toggleLabel = showCode ? "Show diagram" : "Show code"; - return ( - - setShowCode((value) => !value)} - aria-label={toggleLabel} - /> - } - > - {showCode ? : } - - {toggleLabel} - - ) - } - > - {showDiagram ? ( - - - {code} - - } - > - - - {searching ? ( - - ) : null} - - ) : ( - children - )} - - ); -} - -interface SuspenseShikiCodeBlockProps { - className: string | undefined; - code: string; - themeName: DiffThemeName; - isStreaming: boolean; -} - -function SuspenseShikiCodeBlock({ - className, - code, - themeName, - isStreaming, -}: SuspenseShikiCodeBlockProps) { - const [hasStreamed, setHasStreamed] = useState(isStreaming); - if (isStreaming && !hasStreamed) setHasStreamed(true); - const language = extractFenceLanguage(className); - const cacheKey = createHighlightCacheKey(code, language, themeName); - // Once lines are mounted individually, keep that renderer when streaming - // finishes so switching to cached HTML cannot clear an existing selection. - const cachedHighlightedHtml = - !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; - - if (cachedHighlightedHtml != null) { - return ( -
- ); - } - - return ( - - ); -} - -interface UncachedShikiCodeBlockProps { - code: string; - language: string; - themeName: DiffThemeName; - cacheKey: string; - isStreaming: boolean; - preserveLines: boolean; -} - -function UncachedShikiCodeBlock({ - code, - language, - themeName, - cacheKey, - isStreaming, - preserveLines, -}: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); - const incrementalHighlight = preserveLines - ? createIncrementalHighlightedDocument(highlighter, language, themeName) - : null; - const highlighted = (() => { - try { - if (incrementalHighlight) return incrementalHighlight(code); - return preserveLines - ? highlighter.codeToHast(code, { lang: language, theme: themeName }) - : highlighter.codeToHtml(code, { lang: language, theme: themeName }); - } catch (error) { - console.warn( - `Code highlighting failed for language "${language}", falling back to plain text.`, - error instanceof Error ? error.message : error, - ); - return preserveLines - ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) - : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); - } - })(); - - useEffect(() => { - if (!isStreaming) { - const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); - highlightedCodeCache.set( - cacheKey, - highlightedHtml, - estimateHighlightedSize(highlightedHtml, code), - ); - } - }, [cacheKey, code, highlighted, isStreaming]); - - return typeof highlighted === "string" ? ( -
- ) : ( -
- -
- ); -} - -interface MarkdownFileLinkProps { - environmentId: EnvironmentId | null; - href: string; - targetPath: string; - iconPath: string; - workspaceRelativePath: string | null; - panelPath: string | null; - line?: number | undefined; - label: string; - copyMarkdown: string; - theme: "light" | "dark"; - threadRef?: ScopedThreadRef | undefined; - onOpen?: ((targetPath: string) => Promise>) | undefined; - openExternally?: boolean; - openWithEditors?: readonly { readonly id: EditorId; readonly label: string }[]; - onOpenWith?: - | ((path: string, editor: EditorId) => Promise>) - | undefined; - onOpenInPanel: (panelPath: string, line: number | undefined) => void; - resolveCopyPaths: () => Promise>; - openInEditorMenuLabel: string; - onOpenInBrowser?: (() => Promise>) | undefined; - onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - revealLabel?: string | undefined; -} - -const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; - -/** - * Fork: inline code copies its text when clicked, unless the click ends a text selection, and - * marks itself copied for a moment. - */ -function CopyableInlineCode({ - text, - children, - ...props -}: React.ComponentProps<"code"> & { text: string }) { - const [copied, setCopied] = useState(false); - useEffect(() => { - if (!copied) return; - const timeout = window.setTimeout(() => setCopied(false), 1_200); - return () => window.clearTimeout(timeout); - }, [copied]); - return ( - - { - if ((window.getSelection()?.toString() ?? "").length > 0) return; - event.stopPropagation(); - void writeTextToClipboard(text).then( - () => setCopied(true), - () => undefined, - ); - }} - /> - } - > - {children} - - {copied ? "Copied" : "Click to copy"} - - ); -} - -function normalizeMarkdownLinkHrefKey(href: string): string { - const normalizedHref = normalizeMarkdownLinkDestination(href); - const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; - return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) - ? rewrittenHref.replaceAll("\\", "/") - : rewrittenHref; -} - -const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; - -const failedFaviconHosts = new Set(); - -function isGitHubHost(host: string): boolean { - const hostname = host.toLowerCase(); - return hostname === "github.com" || hostname.endsWith(".github.com"); -} - -const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ - host, - gitHubRefType, -}: { - host: string; - gitHubRefType?: GitHubRefType | null | undefined; -}) { - const [failedHost, setFailedHost] = useState(null); - const brandIcon = isGitHubHost(host); - const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`); - return ( - - {gitHubRefType ? ( - - ) : brandIcon ? ( - - ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( - - ) : ( - { - failedFaviconHosts.add(host); - setFailedHost(host); - }} - /> - )} - - ); -}); - -const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; -const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( - "max-h-[30rem]", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, -); -const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; -const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( - "h-auto w-auto object-contain", - CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, -); - -function markdownImageCopy(alt: string, src: string, title: string | undefined): string { - const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); - const titleSuffix = - title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; -} - -/** - * `maxHeightRem` folds a height cap into the width bound: `max-height` alone - * would not feed back through `aspect-ratio` once `width` is definite, so a - * tall image would keep a box wider than the picture it draws. - */ -function authoredImageSizeStyle( - width: string | number | undefined, - height: string | number | undefined, - maxHeightRem = 30, -): CSSProperties | undefined { - const parsedWidth = Number(width); - const parsedHeight = Number(height); - const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; - const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; - if (hasWidth && hasHeight) { - return { - width: parsedWidth, - height: "auto", - aspectRatio: `${parsedWidth} / ${parsedHeight}`, - maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, - }; - } - if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; - if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; - return undefined; -} - -const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( - CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); -const MarkdownLinkContext = React.createContext(false); - -function expandableMarkdownImageProps( - onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - alt: string, -) { - if (!onImageExpand) return {}; - const previewName = alt.trim() || "image"; - const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { - if (event.currentTarget.closest("a")) return; - event.preventDefault(); - event.stopPropagation(); - const item = markdownImageItems.get(event.currentTarget); - if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); - }; - return { - role: "button" as const, - tabIndex: 0, - "aria-label": `Preview ${previewName}`, - onClick: expand, - onKeyDown: (event: ReactKeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") expand(event); - }, - }; -} - -function ChatMarkdownMediaUnavailableLabel(props: { - readonly alt: string; - readonly kind?: "image" | "video" | undefined; - readonly detail?: string | undefined; -}) { - const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; - return ( - - - {[label, props.alt, props.detail].filter(Boolean).join(" · ")} - - ); -} - -function ChatMarkdownImageFallback(props: { - readonly alt: string; - readonly copyMarkdown?: string | undefined; - readonly kind?: "image" | "video"; - readonly actionsSource?: MediaActionSource | undefined; - readonly detail?: string | undefined; -}) { - const content = ( - - - - ); - return props.actionsSource ? ( - {content} - ) : ( - content - ); -} - -const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( - "aspect-video w-full overflow-hidden bg-muted/60", - CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, - CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, -); - -/** - * Callers key this on the file's identity, not its URL: a re-signed URL for - * the same file keeps the decoded image on screen while the new bytes arrive, - * and a different file starts from the slot again. - */ -function ChatMarkdownImage(props: { - readonly src: string | null; - readonly sourceFailed?: boolean | undefined; - readonly unavailableDetail?: string | undefined; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly standalone: boolean; - readonly className?: string | undefined; - readonly style?: CSSProperties | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly actionsSource: MediaActionSource; - readonly originalUrl?: string | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const [loadedSrc, setLoadedSrc] = useState(null); - const [failedSrc, setFailedSrc] = useState(null); - const src = props.src ?? loadedSrc; - const failed = props.sourceFailed === true || (src !== null && failedSrc === src); - const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); - // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = (image: HTMLImageElement | null) => { - if (!image) return; - if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - markdownImageItems.set(image, { - src, - name: props.alt.trim() || "image", - actionsSource: props.actionsSource, - ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), - }); - }; - const imageEvents = (loadingSrc: string) => ({ - onLoad: () => { - setLoadedSrc(loadingSrc); - setFailedSrc(null); - }, - onError: () => { - setFailedSrc(loadingSrc); - setLoadedSrc(null); - }, - }); - - if (settled) { - return ( - - {props.alt} - - ); - } - if (!props.standalone) { - return failed ? ( - - ) : ( - - ); - } - return ( - - - {failed ? ( - - - - ) : src !== null ? ( - {props.alt} - ) : null} - - - ); -} - -function ChatMarkdownVideo(props: { - readonly src: string | null; - readonly alt: string; - readonly copyMarkdown: string | undefined; - readonly originalUrl?: string | undefined; - readonly sourceFailed?: boolean | undefined; - readonly style?: CSSProperties | undefined; - readonly mediaIdentity?: string | undefined; - readonly actionsSource?: MediaActionSource | undefined; - readonly onRetry?: (() => Promise) | undefined; -}) { - return ( - - ); -} - -export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { - readonly environmentId: EnvironmentId; - readonly resource: Extract< - AssetResource, - { - readonly _tag: - | "attachment" - | "workspace-file" - | "media-file" - | "github-media" - | "tool-output-image"; - } - >; - readonly kind?: "image" | "video"; - readonly alt: string; - readonly copyMarkdown?: string; - readonly srcFragment?: string; - readonly standalone?: boolean | undefined; - readonly maxHeightRem?: number | undefined; - readonly style?: CSSProperties | undefined; - readonly className?: string | undefined; - readonly imageProps?: - | Omit, "src" | "alt" | "className" | "style"> - | undefined; - readonly originalUrl?: string | undefined; - readonly framed?: boolean | undefined; - readonly fallbackSrc?: string | undefined; - readonly workspaceRoot?: string | undefined; - readonly workspaceMutationId?: string | null | undefined; - readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; -}) { - const assetUrl = useAssetUrlState(props.environmentId, props.resource); - const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); - const connectionReady = usePreparedConnection(props.environmentId)._tag === "Some"; - const resource = props.resource; - const refreshToken = JSON.stringify([props.environmentId, resource, props.workspaceMutationId]); - const handledRefreshToken = useRef(undefined); - const assetLoading = assetUrl._tag === "Loading" || assetUrl.waiting === true; - useEffect(() => { - if (!connectionReady) { - handledRefreshToken.current = null; - return; - } - if ( - props.workspaceMutationId === undefined || - props.kind === "video" || - (resource._tag !== "media-file" && resource._tag !== "workspace-file") || - handledRefreshToken.current === refreshToken - ) { - return; - } - if (assetLoading) { - // Only the initial mount shares a pending request; edits and reconnects need a new one. - if (handledRefreshToken.current === undefined) { - handledRefreshToken.current = refreshToken; - } - return; - } - handledRefreshToken.current = refreshToken; - void refreshAssetUrl().catch(() => undefined); - }, [ - assetLoading, - connectionReady, - props.kind, - props.workspaceMutationId, - refreshAssetUrl, - refreshToken, - resource._tag, - ]); - const path = - resource._tag === "media-file" - ? resource.path - : resource._tag === "workspace-file" && props.workspaceRoot - ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` - : undefined; - const reference = path - ? mediaFileReference(path, props.workspaceRoot) - : props.originalUrl - ? mediaUrlReference(props.originalUrl) - : undefined; - const relativePath = reference?.kind === "file" ? reference.relativePath : undefined; - const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined; - const src = - assetUrl._tag === "Success" - ? assetUrl.url + (props.srcFragment ?? "") - : fallbackSrc === undefined - ? null - : fallbackSrc + (props.srcFragment ?? ""); - const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; - const maxHeightRem = props.maxHeightRem ?? 30; - const style = - props.style ?? - (knownSize - ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) - : maxHeightRem !== 30 - ? { maxHeight: `${maxHeightRem}rem` } - : undefined); - const actionsSource: MediaActionSource = { - kind: props.kind ?? "image", - name: props.alt || (props.kind ?? "image"), - src, - ...(fallbackSrc === undefined - ? { asset: { environmentId: props.environmentId, resource } } - : {}), - ...(reference ? { reference } : {}), - ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file") - ? { - onOpenFile: () => - useRightPanelStore - .getState() - .openFile( - { environmentId: props.environmentId, threadId: resource.threadId }, - relativePath, - ), - } - : {}), - }; - - if (props.kind === "video") { - return ( - - ); - } - - return ( - - ); -}); - -function leadingExternalLinkTextLength(text: string): number { - const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; - if (protocol) return protocol.length; - return Math.min(text.length, 1); -} - -function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); -} - -function plainHastText(node: unknown): string | null { - if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { - return null; - } - const parts = node.children.map((child) => { - if ( - child && - typeof child === "object" && - "type" in child && - child.type === "text" && - "value" in child && - typeof child.value === "string" - ) { - return child.value; - } - return null; - }); - return parts.every((part) => part !== null) ? parts.join("") : null; -} - -function hastPlainTextDeep(node: unknown): string { - if (!node || typeof node !== "object") return ""; - if ("type" in node && node.type === "text" && "value" in node && typeof node.value === "string") { - return node.value; - } - if (!("children" in node) || !Array.isArray(node.children)) return ""; - return node.children.map(hastPlainTextDeep).join(""); -} - -function hastHasText(node: unknown): boolean { - if (!node || typeof node !== "object") return false; - if ( - "type" in node && - node.type === "text" && - "value" in node && - typeof node.value === "string" && - node.value.trim().length > 0 - ) { - return true; - } - return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); -} - -const SANITIZED_FRAGMENT_PREFIX = "user-content-"; - -function decodeMarkdownFragmentId(href: string): string { - const encodedId = href.slice(1); - try { - return decodeURIComponent(encodedId); - } catch { - return encodedId; - } -} - -function normalizeSanitizedFragmentId(id: string): string { - let normalizedId = id; - while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { - normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); - } - return normalizedId; -} - -function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { - const decodedId = decodeMarkdownFragmentId(href); - const normalizedId = normalizeSanitizedFragmentId(decodedId); - const matchesFragment = (element: HTMLElement) => - element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; - const markdownRoot = anchor.closest(".chat-markdown"); - if (markdownRoot) { - const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); - const localTarget = localTargets.find(matchesFragment); - if (localTarget) return localTarget; - } - - return ( - document.getElementById(decodedId) ?? - Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? - null - ); -} - -function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - - // Never let the browser follow the fragment or write it to the URL: desktop keeps - // its route in the hash, so replacing the hash navigates away from the thread. - event.preventDefault(); - findMarkdownFragmentTarget(event.currentTarget, href)?.scrollIntoView({ block: "start" }); -} - -/** - * A bare Slack message link, named `#channel · Author` once the connected Slack account can read - * it. Until then, and whenever Slack is off or disconnected, it stays its URL. - */ -function SlackLinkText({ - environmentId, - host, - url, -}: { - environmentId: EnvironmentId; - host: string; - url: string; -}) { - const link = parseComposerObjectLink(url); - const enabled = useEnvironmentSettings(environmentId, (s) => s.enableSlackIntegration); - const connection = useEnvironmentQuery( - enabled ? slackEnvironment.connection({ environmentId, input: {} }) : null, - ); - const preview = useEnvironmentQuery( - link?.kind === "slack-message" && enabled && connection.data?.phase === "connected" - ? slackEnvironment.linkPreview({ - environmentId, - input: { - channelId: link.channelId, - ts: link.ts, - ...(link.threadTs === null ? {} : { threadTs: link.threadTs }), - url, - }, - }) - : null, - ).data; - if (!preview) { - return ( - - {url} - - ); - } - return ( - - {url} - - ); -} - -/** - * A bare Discord message link, named `#channel · Author` once the connected bot can read it. - * Until then, and whenever Discord is off or disconnected, it stays its URL. - */ -function DiscordLinkText({ - environmentId, - host, - url, -}: { - environmentId: EnvironmentId; - host: string; - url: string; -}) { - const link = parseComposerObjectLink(url); - const enabled = useEnvironmentSettings(environmentId, (s) => s.enableDiscordIntegration); - const connection = useEnvironmentQuery( - enabled ? discordEnvironment.connection({ environmentId, input: {} }) : null, - ); - const preview = useEnvironmentQuery( - link?.kind === "discord-message" && enabled && connection.data?.phase === "connected" - ? discordEnvironment.linkPreview({ - environmentId, - input: { - guildId: link.guildId, - channelId: link.channelId, - messageId: link.messageId, - url, - }, - }) - : null, - ).data; - if (!preview) { - return ( - - {url} - - ); - } - return ( - - {url} - - ); -} - -/** - * A bare Granola note link, named by its meeting's title once the connected account can read it. - * Until then, and whenever Granola is off or disconnected, it reads "Granola note". - */ -function GranolaLinkText({ - environmentId, - host, - url, -}: { - environmentId: EnvironmentId; - host: string; - url: string; -}) { - const link = parseComposerObjectLink(url); - const enabled = useEnvironmentSettings(environmentId, (s) => s.enableGranolaIntegration); - const connection = useEnvironmentQuery( - enabled ? granolaEnvironment.connection({ environmentId, input: {} }) : null, - ); - const note = useEnvironmentQuery( - link?.kind === "granola-note" && enabled && connection.data?.phase === "connected" - ? granolaEnvironment.noteSummary({ environmentId, input: { id: link.noteId } }) - : null, - ).data; - if (!note) { - return ( - - {url} - - ); - } - return ( - - {url} - - ); -} - -type HeadingHastNode = { - type?: string; - tagName?: string; - properties?: Record; - children?: HeadingHastNode[]; -}; - -function githubHeadingSlug(text: string): string { - return text - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "") - .replace(/ /g, "-"); -} - -/** - * Like the sanitizer's ids, they carry the `user-content-` prefix so they cannot clobber app - * element ids; fragment lookup strips it. - */ -function rehypeHeadingIds() { - return (tree: HeadingHastNode) => { - const taken = new Set(); - const collect = (node: HeadingHastNode) => { - const id = node.properties?.id; - if (typeof id === "string") taken.add(id); - node.children?.forEach(collect); - }; - collect(tree); - const nextSuffix = new Map(); - const visit = (node: HeadingHastNode) => { - if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) { - const slug = githubHeadingSlug(hastPlainTextDeep(node)); - if (node.properties?.id === undefined && slug) { - let count = nextSuffix.get(slug) ?? 0; - let id = `${SANITIZED_FRAGMENT_PREFIX}${slug}`; - while (taken.has(id)) { - count += 1; - id = `${SANITIZED_FRAGMENT_PREFIX}${slug}-${count}`; - } - nextSuffix.set(slug, count); - taken.add(id); - node.properties = { ...node.properties, id }; - } - return; - } - node.children?.forEach(visit); - }; - visit(tree); - }; -} - -// Heading ids are added after sanitizing, which would prefix them a second time. -const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ - ...CHAT_MARKDOWN_REHYPE_PLUGINS, - rehypeHeadingIds, -] satisfies NonNullable; - -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; - -function MarkdownExternalLinkContent({ - host, - gitHubRefType, - plainText, - children, -}: { - host: string; - gitHubRefType?: GitHubRefType | null | undefined; - plainText: string | null; - children: ReactNode; -}) { - if (plainText) { - const leadingLength = leadingExternalLinkTextLength(plainText); - return ( - <> - - - {plainText.slice(0, leadingLength)} - - {breakableExternalLinkText(plainText.slice(leadingLength))} - - ); - } - - const childNodes = Children.toArray(children); - const firstChild = childNodes[0]; - - if (typeof firstChild === "string" && firstChild.length > 0) { - const leadingLength = leadingExternalLinkTextLength(firstChild); - return ( - <> - - - {firstChild.slice(0, leadingLength)} - - {breakableExternalLinkText(firstChild.slice(leadingLength))} - {childNodes.slice(1)} - - ); - } - - return ( - <> - - - {firstChild} - - {childNodes.slice(1)} - - ); -} - -const MarkdownFileLink = memo(function MarkdownFileLink({ - environmentId, - href, - targetPath, - iconPath, - workspaceRelativePath, - panelPath, - line, - label, - copyMarkdown, - theme, - threadRef, - onOpen, - onOpenInPanel, - openExternally, - openWithEditors, - onOpenWith, - openInEditorMenuLabel, - onOpenInBrowser, - onOpenMedia, - onReveal, - revealLabel, - resolveCopyPaths, -}: MarkdownFileLinkProps) { - const handleOpenInEditor = () => { - if (!onOpen) { - return; - } - void (async () => { - try { - const result = await onOpen(targetPath); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-editor", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleOpenInFilePreview = () => { - if (openExternally && onOpen) { - handleOpenInEditor(); - return; - } - if (threadRef && panelPath) { - onOpenInPanel(panelPath, line); - return; - } - if (onOpenMedia) { - onOpenMedia(); - return; - } - handleOpenInEditor(); - }; - - const handleOpenInBrowser = () => { - if (!onOpenInBrowser) { - return; - } - void (async () => { - try { - const result = await onOpenInBrowser(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "open-file-in-browser", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open file in browser", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleRevealInFileManager = () => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }; - - const handleCopy = (resolveValue: () => Promise, title: string) => { - if (typeof window === "undefined" || !navigator.clipboard?.writeText) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: "Clipboard API unavailable.", - }), - ); - return; - } - - const value = resolveValue().then((text) => formatFilePathForClipboard(text, targetPath)); - // Start the write before workspace lookup finishes so Safari retains the user gesture. - const write = - typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined" - ? navigator.clipboard.write([ - new ClipboardItem({ - "text/plain": value.then((text) => new Blob([text], { type: "text/plain" })), - }), - ]) - : value.then((text) => navigator.clipboard.writeText(text)); - void Promise.all([write, value]).then( - ([, text]) => { - toastManager.add({ - type: "success", - title: `${title} copied`, - description: text, - }); - }, - (error) => { - reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, - error, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to copy ${title.toLowerCase()}`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }; - - const showFileContextMenu = async (position: { x: number; y: number }) => { - const api = readLocalApi(); - if (!api) return; - - try { - const copyItems = markdownFileCopyMenuItems({ targetPath, workspaceRelativePath }); - const clicked = await api.contextMenu.show( - [ - ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), - ...(onOpenWith && openWithEditors?.length - ? [ - { - id: "open-with", - label: "Open with", - children: openWithEditors.map((editor) => ({ - id: `editor:${editor.id}`, - label: editor.label, - })), - }, - ] - : []), - ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) - : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - ...copyItems.map(({ id, label }) => ({ id, label })), - ] as const, - position, - ); - - if (clicked === "preview-media") { - onOpenMedia?.(); - return; - } - if (clicked?.startsWith("editor:") && onOpenWith) { - const result = await onOpenWith(targetPath, clicked.slice("editor:".length) as EditorId); - if (result._tag === "Failure") - toastManager.add({ - type: "error", - title: "Could not open file", - description: targetPath, - }); - return; - } - if (clicked === "open") { - handleOpenInEditor(); - return; - } - if (clicked === "open-in-browser") { - handleOpenInBrowser(); - return; - } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } - if (clicked === "copy-relative" || clicked === "copy-full") { - handleCopy( - () => - resolveCopyPaths().then((paths) => - clicked === "copy-relative" ? paths.relativePath : paths.fullPath, - ), - clicked === "copy-relative" ? "Relative path" : "Full path", - ); - } - } catch (cause) { - reportMarkdownActionFailure( - { operation: "show-file-context-menu", target: targetPath }, - cause, - ); - } - }; - - const handleContextMenu = (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - const position = - event.clientX === 0 && event.clientY === 0 - ? (() => { - const bounds = event.currentTarget.getBoundingClientRect(); - return { x: bounds.left, y: bounds.bottom }; - })() - : { x: event.clientX, y: event.clientY }; - void showFileContextMenu(position); - }; - - const canOpenInEditor = onOpen !== undefined; - const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); - const hasPrimaryAction = hasMarkdownFilePrimaryAction({ - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - canOpenMedia: onOpenMedia !== undefined, - }); - const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath, - canOpenInEditor, - canOpenInBrowser, - canOpenInPanel, - }); - - return ( - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction && !openExternally) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - ); -}, areMarkdownFileLinkPropsEqual); +} -function areMarkdownFileLinkPropsEqual( - previous: Readonly, - next: Readonly, -): boolean { - return ( - previous.href === next.href && - previous.targetPath === next.targetPath && - previous.iconPath === next.iconPath && - previous.environmentId === next.environmentId && - previous.workspaceRelativePath === next.workspaceRelativePath && - previous.panelPath === next.panelPath && - previous.line === next.line && - previous.label === next.label && - previous.copyMarkdown === next.copyMarkdown && - previous.theme === next.theme && - previous.threadRef === next.threadRef && - previous.openWithEditors === next.openWithEditors && - previous.onOpenWith === next.onOpenWith && - previous.openExternally === next.openExternally && - previous.onOpen === next.onOpen && - previous.onOpenInPanel === next.onOpenInPanel && - previous.openInEditorMenuLabel === next.openInEditorMenuLabel && - previous.onOpenInBrowser === next.onOpenInBrowser && - previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.resolveCopyPaths === next.resolveCopyPaths && - previous.revealLabel === next.revealLabel +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; + +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); +} + +function orderedListGutterStyle( + itemCount: number, + start: unknown, +): { "--list-gutter": string } | undefined { + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 1) return undefined; + return { "--list-gutter": `${markerWidth + 2}ch` }; +} + +const GITHUB_ALERT_PRESENTATIONS: Record< + string, + { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } +> = { + note: { + label: "Note", + Icon: InfoIcon, + borderClassName: "border-blue-500/70", + titleClassName: "text-blue-600 dark:text-blue-400", + }, + tip: { + label: "Tip", + Icon: LightbulbIcon, + borderClassName: "border-emerald-500/70", + titleClassName: "text-emerald-600 dark:text-emerald-400", + }, + important: { + label: "Important", + Icon: MessageSquareWarningIcon, + borderClassName: "border-purple-500/70", + titleClassName: "text-purple-600 dark:text-purple-400", + }, + warning: { + label: "Warning", + Icon: TriangleAlertIcon, + borderClassName: "border-amber-500/70", + titleClassName: "text-amber-600 dark:text-amber-500", + }, + caution: { + label: "Caution", + Icon: OctagonAlertIcon, + borderClassName: "border-red-500/70", + titleClassName: "text-red-600 dark:text-red-400", + }, +}; + +function normalizeMarkdownLinkHrefKey(href: string): string { + const normalizedHref = normalizeMarkdownLinkDestination(href); + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } +// Heading ids are added after sanitizing, which would prefix them a second time. +const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ + ...CHAT_MARKDOWN_REHYPE_PLUGINS, + rehypeHeadingIds, +] satisfies NonNullable; + +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + const NO_MARKDOWN_EDITORS: ReadonlyArray = []; type MarkdownServerConfigFields = ReturnType; @@ -2758,27 +604,9 @@ function useChatMarkdownState({ availableEditors, revealInFileManagerLabel, } = useAtomValue(markdownServerConfigFieldsAtom(environmentId)); - const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const fileOpenSettings = serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; const projects = useProjects(); - const allAvailableEditors = (() => [ - ...availableEditors, - ...fileOpenSettings.customEditors.map((editor) => editor.id), - ])(); - const openWithEditors = (() => - [ - ...EDITORS.filter( - (editor) => editor.id !== "file-manager" && availableEditors.includes(editor.id), - ), - ...fileOpenSettings.customEditors, - ].map(({ id, label }) => ({ id, label })))(); - const [preferredEditor] = usePreferredEditor(allAvailableEditors); - const customEditorLabel = fileOpenSettings.customEditors.find( - (editor) => editor.id === preferredEditor, - )?.label; - const preferredEditorMenuLabel = customEditorLabel - ? `Open in ${customEditorLabel}` - : openInEditorMenuLabel(preferredEditor); + const [preferredEditor] = usePreferredEditor(availableEditors); + const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, @@ -3021,7 +849,6 @@ function useChatMarkdownState({ fileLinkMeta.workspaceRelativePath ?? (!canPreviewMedia && isHostPath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); - const fileOpenTarget = resolveFileOpenTarget(fileLinkMeta.filePath, fileOpenSettings); return ( - openInPreferredEditor( - markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), - fileOpenTarget === "t3" ? undefined : fileOpenTarget, - ), - } - : {})} - openExternally={fileOpenTarget !== "t3"} - openWithEditors={openWithEditors} - {...(canUseShellActions - ? { - onOpenWith: async (_path: string, editor: EditorId) => + onOpen={ + canUseShellActions + ? async () => openInPreferredEditor( markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), - editor, - ), - } - : {})} + ) + : undefined + } resolveCopyPaths={async () => markdownFileCopyPaths(fileLinkMeta, await resolveHostPath(), cwd) } @@ -3161,6 +976,177 @@ function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { } // Keep component types stable when streaming changes the message state. +function CopyableInlineCode({ + text, + children, + ...props +}: React.ComponentProps<"code"> & { text: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timeout = window.setTimeout(() => setCopied(false), 1_200); + return () => window.clearTimeout(timeout); + }, [copied]); + return ( + + { + if ((window.getSelection()?.toString() ?? "").length > 0) return; + event.stopPropagation(); + void writeTextToClipboard(text).then( + () => setCopied(true), + () => undefined, + ); + }} + /> + } + > + {children} + + {copied ? "Copied" : "Click to copy"} + + ); +} + +/** + * A bare Slack message link, named `#channel · Author` once the connected Slack account can read + * it. Until then, and whenever Slack is off or disconnected, it stays its URL. + */ +function SlackLinkText({ + environmentId, + host, + url, +}: { + environmentId: EnvironmentId; + host: string; + url: string; +}) { + const link = parseComposerObjectLink(url); + const enabled = useEnvironmentSettings(environmentId, (s) => s.enableSlackIntegration); + const connection = useEnvironmentQuery( + enabled ? slackEnvironment.connection({ environmentId, input: {} }) : null, + ); + const preview = useEnvironmentQuery( + link?.kind === "slack-message" && enabled && connection.data?.phase === "connected" + ? slackEnvironment.linkPreview({ + environmentId, + input: { + channelId: link.channelId, + ts: link.ts, + ...(link.threadTs === null ? {} : { threadTs: link.threadTs }), + url, + }, + }) + : null, + ).data; + if (!preview) { + return ( + + {url} + + ); + } + return ( + + {url} + + ); +} + +/** + * A bare Discord message link, named `#channel · Author` once the connected bot can read it. + * Until then, and whenever Discord is off or disconnected, it stays its URL. + */ +function DiscordLinkText({ + environmentId, + host, + url, +}: { + environmentId: EnvironmentId; + host: string; + url: string; +}) { + const link = parseComposerObjectLink(url); + const enabled = useEnvironmentSettings(environmentId, (s) => s.enableDiscordIntegration); + const connection = useEnvironmentQuery( + enabled ? discordEnvironment.connection({ environmentId, input: {} }) : null, + ); + const preview = useEnvironmentQuery( + link?.kind === "discord-message" && enabled && connection.data?.phase === "connected" + ? discordEnvironment.linkPreview({ + environmentId, + input: { + guildId: link.guildId, + channelId: link.channelId, + messageId: link.messageId, + url, + }, + }) + : null, + ).data; + if (!preview) { + return ( + + {url} + + ); + } + return ( + + {url} + + ); +} + +/** + * A bare Granola note link, named by its meeting's title once the connected account can read it. + * Until then, and whenever Granola is off or disconnected, it reads "Granola note". + */ +function GranolaLinkText({ + environmentId, + host, + url, +}: { + environmentId: EnvironmentId; + host: string; + url: string; +}) { + const link = parseComposerObjectLink(url); + const enabled = useEnvironmentSettings(environmentId, (s) => s.enableGranolaIntegration); + const connection = useEnvironmentQuery( + enabled ? granolaEnvironment.connection({ environmentId, input: {} }) : null, + ); + const note = useEnvironmentQuery( + link?.kind === "granola-note" && enabled && connection.data?.phase === "connected" + ? granolaEnvironment.noteSummary({ environmentId, input: { id: link.noteId } }) + : null, + ).data; + if (!note) { + return ( + + {url} + + ); + } + return ( + + {url} + + ); +} + const CHAT_MARKDOWN_COMPONENTS = { h1: markdownHeadingRenderer(1), h2: markdownHeadingRenderer(2), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 63d2fd1ca613..ad6ff10291cc 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,31 +1,10 @@ -import { - findRecordedWorktreeSetup, - isThreadWokeUnseen, - mergeDirectPreviewAnnotation, - resolveVisibleWorktreeSetup, -} from "./ChatView.logic"; -import { - recallCheckoutIsRepo, - rememberCheckoutIsRepo, - threadShellHasStarted, -} from "./ChatView.logic"; -import { - ANTIGRAVITY_DEFAULT_MODEL, - ProviderDriverKind, - type ServerProvider, -} from "@t3tools/contracts"; -import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { - selectActiveRightPanel, - selectThreadRightPanelState, - useRightPanelStore, - type RightPanelSurface, -} from "../rightPanelStore"; +import { findRecordedWorktreeSetup, mergeDirectPreviewAnnotation } from "./ChatView.logic"; +import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; +import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { deriveProviderInstanceEntries } from "../providerInstances"; import { CommandId, EnvironmentId, - EventId, MessageId, ProjectId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -34,86 +13,42 @@ import { ProviderThreadId, ThreadId, RunId, - type ScopedThreadRef, TurnItemId, type OrchestrationV2ProjectedTurnItem, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { Atom, AsyncResult } from "effect/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; -import type { Thread, TurnDiffSummary } from "../types"; +import type { Thread } from "../types"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { - agentControlledBrowserCloseConfirmation, - ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, - resolveBackgroundDraftWorkspaceOptions, - resolveComposerInteractionMode, - restorePlanFollowUpComposer, resolveComposerProviderSelection, - resolveProactiveTurnDiffAction, - resolveDraftHeroState, - resolveExplicitDiffOpenOnActivate, resolveWorktreeSetupProgress, - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resetHeldThreadTimeline, resolveThreadSwitchTimeline, threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, - scheduleEnvironmentReconnectWarning, - codexArtifactTemplatePromptToAppend, - shouldDockDraftHeroForSubmission, - shouldReleaseTimelineAnchorForToolActivity, - shouldRefocusComposerOnWindowFocus, - shouldOpenProactivePullRequest, - shouldRetargetThreadPullRequestPanel, - shouldOpenProactiveTurnDiff, - shouldRenderPreviewMiniPlayer, - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - branchMismatchKey, - buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, deriveLockedProvider, - dismissBranchMismatchForSession, - getStartedThreadModelChangeBlockReason, - hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, - isBranchMismatchDismissedForSession, - reconcileMountedTerminalThreadIds, - resolveTerminalWorktreePath, resolveDraftPromotionNavigationTarget, - resolveEffectiveInteractionMode, - resolveThreadMetadataUpdateForNextTurn, - resolveSendEnvMode, - startNewThreadForProject, - shouldShowBranchMismatchBanner, - shouldShowPlanFollowUpPrompt, - shouldWriteThreadErrorToCurrentServerThread, waitForRevertedMessage, - prepareRevertedMessageAttachments, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; -const helloWorldTemplate: CodexArtifactTemplate = { - artifactKind: "document", - displayName: "Hello World", - skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", - skillName: "artifact-template-hello-world", -}; function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ @@ -202,112 +137,9 @@ describe("resolveDraftPromotionNavigationTarget", () => { }), ).toBeNull(); }); - - it("navigates once the run starts or startup stops", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ - latestRun: { ...preparingRun, status: "failed" as const }, - }), - backgroundSubmissionPending: false, - }), - ).toBe(serverThreadRef); - }); - - it("defers while a background submission is pending", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - -describe("resolveEffectiveInteractionMode", () => { - it("forces build mode when legacy plan mode is disabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: false, - composerInteractionMode: "plan", - threadInteractionMode: "plan", - }), - ).toBe("default"); - }); - - it("uses the saved mode while legacy plan mode is enabled", () => { - expect( - resolveEffectiveInteractionMode({ - planModeEnabled: true, - composerInteractionMode: null, - threadInteractionMode: "plan", - }), - ).toBe("plan"); - }); -}); - -describe("resolveThreadMetadataUpdateForNextTurn", () => { - const modelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }; - - it("adopts the checkout branch without clearing the worktree attachment", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - currentBranch: "feature/thread", - nextBranch: "feature/checkout", - }), - ).toEqual({ branch: "feature/checkout" }); - }); - - it("does not write metadata when the model and branch are unchanged", () => { - expect( - resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: modelSelection, - nextModelSelection: modelSelection, - currentBranch: "feature/current", - nextBranch: "feature/current", - }), - ).toBeNull(); - }); }); describe("deriveComposerSendState", () => { - it("treats expired terminal pills as non-sendable content", () => { - const state = deriveComposerSendState({ - prompt: "[Terminal 1](t3-context://v1/terminal/ctx-expired)", - imageCount: 0, - terminalContexts: [ - { - id: "ctx-expired", - threadId, - terminalId: "default", - terminalLabel: "Terminal 1", - lineStart: 4, - lineEnd: 4, - text: "", - createdAt: now, - }, - ], - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.sendableTerminalContexts).toEqual([]); - expect(state.expiredTerminalContextCount).toBe(1); - expect(state.hasSendableContent).toBe(false); - }); - it("keeps text sendable while excluding expired terminal pills", () => { const state = deriveComposerSendState({ prompt: `yoo [Terminal 1](t3-context://v1/terminal/ctx-expired) waddup`, @@ -330,321 +162,9 @@ describe("deriveComposerSendState", () => { expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); }); - - it("treats element contexts as sendable content (no text, no images, no terminals)", () => { - const state = deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 1, - }); - - expect(state.trimmedPrompt).toBe(""); - expect(state.expiredTerminalContextCount).toBe(0); - expect(state.hasSendableContent).toBe(true); - }); - - it("does NOT treat zero element contexts as sendable", () => { - expect( - deriveComposerSendState({ - prompt: "", - imageCount: 0, - terminalContexts: [], - elementContextCount: 0, - }).hasSendableContent, - ).toBe(false); - }); -}); - -describe("buildExpiredTerminalContextToastCopy", () => { - it("formats empty and omission guidance", () => { - expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ - title: "Expired terminal context won't be sent", - description: "Remove it or re-add it to include terminal output.", - }); - expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ - title: "Expired terminal contexts omitted from message", - description: "Re-add it if you want that terminal output included.", - }); - }); -}); - -describe("getStartedThreadModelChangeBlockReason", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - }, - { - instanceId: ProviderInstanceId.make("grok"), - requiresNewThreadForModelChange: true, - }, - ]; - - it("allows model changes before a provider session has started", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: false, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toBeNull(); - }); - - it("allows unchanged model selections for restricted providers", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - }), - ).toBeNull(); - }); - - it("blocks started-session model changes for providers that require a new thread", () => { - expect( - getStartedThreadModelChangeBlockReason({ - providers, - hasStartedSession: true, - currentModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-build", - }, - nextModelSelection: { - instanceId: ProviderInstanceId.make("grok"), - model: "grok-other", - }, - }), - ).toEqual({ - title: "Start a new chat to change models", - description: - "This provider does not allow switching models after a conversation has started.", - }); - }); -}); - -describe("resolveSendEnvMode", () => { - it("keeps worktree mode only for git repositories", () => { - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - }); -}); - -describe("branchMismatchKey", () => { - it("builds a key from thread id and both branches", () => { - expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( - "thread-1:feat/a:feat/b", - ); - }); - - it("returns null without a thread or mismatch", () => { - expect(branchMismatchKey(null, { threadBranch: "a", currentBranch: "b" })).toBeNull(); - expect(branchMismatchKey("thread-1", null)).toBeNull(); - }); -}); - -describe("shouldShowBranchMismatchBanner", () => { - const base = { - hasMismatch: true, - isDismissed: false, - composerHasContent: false, - wasShownForCurrentMismatch: false, - }; - - it("stays hidden during passive browsing (even though the composer autofocuses)", () => { - expect(shouldShowBranchMismatchBanner(base)).toBe(false); - }); - - it("shows once the composer has draft content", () => { - expect(shouldShowBranchMismatchBanner({ ...base, composerHasContent: true })).toBe(true); - }); - - it("stays mounted after the draft clears once shown for the current mismatch", () => { - expect(shouldShowBranchMismatchBanner({ ...base, wasShownForCurrentMismatch: true })).toBe( - true, - ); - }); - - it("never shows when dismissed or without a mismatch", () => { - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, isDismissed: true }), - ).toBe(false); - expect( - shouldShowBranchMismatchBanner({ ...base, composerHasContent: true, hasMismatch: false }), - ).toBe(false); - }); -}); - -describe("shouldShowPlanFollowUpPrompt", () => { - const base = { - pendingUserInputCount: 0, - interactionMode: "plan" as const, - latestTurnSettled: true, - hasActionableProposedPlan: true, - hasComposerAttachments: false, - }; - - it("shows plan actions for a settled actionable plan without attachments", () => { - expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); - }); - - it("hides plan actions while the composer has staged attachments", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); - }); - - it("preserves the existing plan follow-up gates", () => { - expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); - expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); - }); -}); - -describe("session branch mismatch dismissal", () => { - it("tracks dismissed keys and treats other keys as active", () => { - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); - dismissBranchMismatchForSession("t1:a:b"); - expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(true); - expect(isBranchMismatchDismissedForSession("t1:a:c")).toBe(false); - expect(isBranchMismatchDismissedForSession(null)).toBe(false); - }); -}); - -describe("resolveTerminalWorktreePath", () => { - it("uses the terminal's checkout identity until neither launch nor session is known", () => { - const threadWorktreePath = "/repo/.worktrees/feature"; - - expect( - resolveTerminalWorktreePath({ - launchContext: { worktreePath: null }, - summary: { worktreePath: "/repo/.worktrees/other" }, - threadWorktreePath, - }), - ).toBeNull(); - - expect( - resolveTerminalWorktreePath({ - launchContext: null, - summary: { worktreePath: null }, - threadWorktreePath, - }), - ).toBeNull(); - - expect( - resolveTerminalWorktreePath({ launchContext: null, summary: null, threadWorktreePath }), - ).toBe(threadWorktreePath); - }); -}); - -describe("reconcileMountedTerminalThreadIds", () => { - it("keeps open threads and makes the active thread most recent", () => { - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ["thread-a", "thread-b", "thread-c"], - openThreadIds: ["thread-a", "thread-b", "thread-c"], - activeThreadId: "thread-a", - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, - }), - ).toEqual(["thread-b", "thread-c", "thread-a"]); - }); - - it("drops closed threads and enforces the hidden mounted cap", () => { - const ids = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, - (_, index) => `thread-${index}`, - ); - expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: ids, - openThreadIds: ids.slice(1), - activeThreadId: null, - activeThreadTerminalOpen: false, - }), - ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); - }); -}); - -describe("shouldWriteThreadErrorToCurrentServerThread", () => { - it("requires the environment, route thread, and target thread to match", () => { - const routeThreadRef = { environmentId, threadId }; - - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { environmentId, id: threadId }, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(true); - expect( - shouldWriteThreadErrorToCurrentServerThread({ - serverThread: null, - routeThreadRef, - targetThreadId: threadId, - }), - ).toBe(false); - }); -}); - -describe("startNewThreadForProject", () => { - it("starts a thread through the supplied shared handler for the active project", () => { - const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; - const projectRef = { environmentId, projectId }; - - expect( - startNewThreadForProject(projectRef, (nextProjectRef) => { - calls.push(nextProjectRef); - return Promise.resolve(); - }), - ).toBe(true); - expect(calls).toEqual([projectRef]); - }); - - it("does nothing when the active project is unavailable", () => { - let called = false; - - expect( - startNewThreadForProject(null, () => { - called = true; - return Promise.resolve(); - }), - ).toBe(false); - expect(called).toBe(false); - }); }); describe("hasServerAcknowledgedLocalDispatch", () => { - it("does not acknowledge unchanged server state", () => { - const localDispatch = createLocalDispatchSnapshot( - makeThread({ latestRun: completedTurn, runtime: readySession }), - ); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "ready", - latestRun: completedTurn, - runtime: readySession, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(false); - }); - it("acknowledges a settled newer background turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), @@ -767,23 +287,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }), ).toBe(true); }); - - it("acknowledges pending user interaction and errors immediately", () => { - const localDispatch = createLocalDispatchSnapshot(makeThread()); - const common = { - localDispatch, - phase: "ready" as const, - latestRun: null, - runtime: null, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }; - - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); - }); }); describe("deriveCommittedServerUserMessageIds", () => { @@ -886,185 +389,6 @@ describe("deriveCommittedServerUserMessageIds", () => { }); }); -describe("agent browser close confirmation", () => { - const surfaces = [ - { id: "browser:one", kind: "preview", resourceId: "tab-1" }, - { id: "browser:two", kind: "preview", resourceId: "tab-2" }, - { id: "diff", kind: "diff" }, - ] satisfies RightPanelSurface[]; - - it("only warns for browsers under active agent control", () => { - expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "none" }, - "tab-2": { controller: "human" }, - }), - ).toBeNull(); - - expect( - agentControlledBrowserCloseConfirmation([surfaces[0]!], { - "tab-1": { controller: "agent" }, - }), - ).toBe( - [ - "Close browser while the agent is using it?", - "The agent is actively controlling this browser. Closing it may interrupt the current browser action.", - ].join("\n"), - ); - }); - - it("counts every agent-controlled browser in a bulk close", () => { - expect( - agentControlledBrowserCloseConfirmation(surfaces, { - "tab-1": { controller: "agent" }, - "tab-2": { controller: "agent" }, - }), - ).toContain("Close 2 browsers"); - }); -}); - -describe("floating browser preview", () => { - it("only hides the duplicate while the same browser is rendered in the panel", () => { - expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:one", - kind: "preview", - resourceId: "tab-1", - }, - ), - ).toBe(false); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { - id: "browser:two", - kind: "preview", - resourceId: "tab-2", - }, - ), - ).toBe(true); - expect( - shouldRenderPreviewMiniPlayer( - { kind: "browser", tabId: "tab-1" }, - { id: "diff", kind: "diff" }, - ), - ).toBe(true); - }); -}); - -describe("proactive panels", () => { - it("opens an existing pull request on entry and follows newly observed links", () => { - expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); - expect(shouldOpenProactivePullRequest("project:repo:42", "project:repo:42")).toBe(false); - expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); - }); - - it("opens a completed diff on entry or when the observed running turn settles", () => { - const turnId = RunId.make("turn-1"); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: undefined, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(true); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: RunId.make("turn-2"), - settledTurnId: turnId, - turnCompleted: true, - }), - ).toBe(false); - expect( - shouldOpenProactiveTurnDiff({ - previousRunningTurnId: turnId, - runningTurnId: null, - settledTurnId: turnId, - turnCompleted: false, - }), - ).toBe(false); - }); -}); - -describe("artifact template composer insertion", () => { - it("does not insert an already-present prompt", () => { - const prompt = "Create a document using this $artifact-template-hello-world about…"; - - expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); - }); -}); - -describe("draft hero submission transition", () => { - it("does not dock the composer before a background submission", () => { - expect( - shouldDockDraftHeroForSubmission({ - isDraftHeroState: true, - activeThreadKey: "environment-local:thread-1", - submissionIntent: "background", - }), - ).toBe(false); - }); - - it("leaves the hero layout while a worktree setup card is on the timeline", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: false, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - expect( - resolveDraftHeroState({ - isLocalDraftThread: true, - hasTimelineEntries: false, - isWorking: false, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - hasWorktreeSetupCard: true, - }), - ).toBe(false); - }); - - it("keeps the composer in the hero layout until navigation after server promotion", () => { - expect( - resolveDraftHeroState({ - isLocalDraftThread: false, - hasTimelineEntries: true, - isWorking: true, - draftHeroDockRequested: false, - backgroundSubmissionPending: true, - }), - ).toBe(true); - }); - - it("does not auto-navigate a background submission after server promotion", () => { - expect( - resolveDraftPromotionNavigationTarget({ - serverThreadRef: { environmentId, threadId }, - serverThread: makeThread({ latestRun: completedTurn }), - backgroundSubmissionPending: true, - }), - ).toBeNull(); - }); -}); - describe("resolveThreadSwitchTimeline", () => { afterEach(() => { resetHeldThreadTimeline(); @@ -1072,53 +396,6 @@ describe("resolveThreadSwitchTimeline", () => { const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; - it("leaves an uncached destination empty while its history loads", () => { - rememberReadyThreadTimeline(held); - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-b", - nextEntries: [], - lastReady: held, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" }); - }); - - it("shows the new thread once its detail is ready", () => { - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["b1"], - lastReady: held, - }), - ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not invent a timeline on the first open of a thread", () => { - expect( - resolveThreadSwitchTimeline({ - loading: true, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - lastReady: null, - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - - it("keeps the held thread workspace cwd with the snapshot", () => { - rememberReadyThreadTimeline({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - expect(peekHeldThreadTimeline()).toEqual({ - ...held, - markdownCwd: "/repo/a", - workspaceRoot: "/repo/a", - }); - }); - it("paints a remembered destination instead of the last-viewed thread", () => { rememberReadyThreadTimeline(held); rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); @@ -1132,28 +409,6 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); }); - it("prefers live entries over a remembered snapshot", () => { - rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] }); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-b", - nextEntries: ["fresh-b"], - }), - ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); - }); - - it("does not keep a remembered snapshot on a resolved empty thread", () => { - rememberReadyThreadTimeline(held); - expect( - resolveThreadSwitchTimeline({ - loading: false, - activeThreadKey: "env-1:thread-a", - nextEntries: [], - }), - ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); - }); - it("does not hold another environment's timeline across a jump", () => { expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); expect( @@ -1166,11 +421,6 @@ describe("resolveThreadSwitchTimeline", () => { ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); }); - it("treats a foreign held timeline as paint-only", () => { - expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); - expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); - }); - it("does not remember a timeline that still has handoff blob previews", () => { expect( timelineHasEphemeralPreviewUrls([ @@ -1227,150 +477,6 @@ describe("resolveThreadSwitchTimeline", () => { }); }); -describe("shouldReleaseTimelineAnchorForToolActivity", () => { - const activeTurnId = RunId.make("active-turn"); - const anchorMessageId = MessageId.make("anchored-message"); - const activeToolEntry = { - id: "tool-entry", - kind: "work" as const, - createdAt: now, - entry: { - id: "active-tool", - createdAt: now, - runId: activeTurnId, - label: "Run command", - tone: "tool" as const, - command: "git status", - }, - }; - - it("releases the send anchor for tool activity in the active turn", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(true); - }); - - it("keeps the anchor while the user reads history", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: false, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }), - ).toBe(false); - }); - - it("ignores tool activity from earlier turns", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - ...activeToolEntry.entry, - runId: RunId.make("previous-turn"), - }, - }, - ], - }), - ).toBe(false); - }); - - it("ignores thinking and error rows without tool activity", () => { - expect( - shouldReleaseTimelineAnchorForToolActivity({ - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [ - { - ...activeToolEntry, - entry: { - id: "thinking-entry", - createdAt: now, - runId: activeTurnId, - label: "Thinking", - tone: "thinking", - }, - }, - { - ...activeToolEntry, - id: "error-entry", - entry: { - id: "error-entry", - createdAt: now, - runId: activeTurnId, - label: "Provider error", - tone: "error", - }, - }, - ], - }), - ).toBe(false); - }); - - it("does nothing without an anchor or running turn", () => { - const input = { - anchorMessageId, - liveFollowEnabled: true, - runningTurnId: activeTurnId, - timelineEntries: [activeToolEntry], - }; - - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( - false, - ); - expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( - false, - ); - }); -}); - -describe("environment reconnect warning grace", () => { - afterEach(() => vi.useRealTimers()); - - it("shows a persistent reconnect after the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - scheduleEnvironmentReconnectWarning(showWarning); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); - expect(showWarning).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(showWarning).toHaveBeenCalledOnce(); - }); - - it("cancels the warning when the connection recovers during the grace period", () => { - vi.useFakeTimers(); - const showWarning = vi.fn(); - - const cancel = scheduleEnvironmentReconnectWarning(showWarning); - cancel(); - vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); - - expect(showWarning).not.toHaveBeenCalled(); - }); - - it("does not reuse elapsed grace from another environment", () => { - const anotherEnvironmentId = EnvironmentId.make("environment-remote"); - - expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); - expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( - false, - ); - }); -}); - describe("resolveComposerProviderSelection", () => { const catalogModels: ServerProvider["models"] = [ { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, @@ -1477,68 +583,6 @@ describe("resolveComposerProviderSelection", () => { }, ); - it("leaves a new draft free to select a different driver", () => { - const original = entry("claudeAgent", "claude_work"); - const selected = entry("codex", "codex_work"); - expect( - deriveLockedProvider({ - thread: makeThread({ - modelSelection: { instanceId: original.instanceId, model: "default" }, - }), - selectedProvider: selected.instanceId, - threadProvider: original.instanceId, - providers: [original.snapshot, selected.snapshot], - }), - ).toBeNull(); - }); - - it("uses the custom instance's capability instead of the default instance", () => { - const defaultEntry = entry("antigravity", "antigravity", { - showInteractionModeToggle: true, - }); - const customEntry = entry("antigravity", "google_work", { - showInteractionModeToggle: false, - }); - const selection = resolveComposerProviderSelection({ - entries: [defaultEntry, customEntry], - candidateInstanceIds: [customEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("uses the fallback provider's plan capability after the draft's instance is disabled", () => { - const disabledEntry = entry("antigravity", "antigravity", { - enabled: false, - showInteractionModeToggle: false, - }); - const fallbackEntry = entry("codex"); - const selection = resolveComposerProviderSelection({ - entries: [disabledEntry, fallbackEntry], - candidateInstanceIds: [disabledEntry.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId); - expect( - resolveComposerInteractionMode({ - provider: selection.selectedProviderEntry?.snapshot, - planModeEnabled: true, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - it("keeps a signed-out selection instead of silently switching providers", () => { const signedOutEntry = entry("antigravity", "google_work", { status: "error", @@ -1558,48 +602,6 @@ describe("resolveComposerProviderSelection", () => { ).toBe("Sign in to Antigravity in provider settings before sending."); }); - it("blocks sends until the selected Antigravity profile is installed", () => { - const provider = entry("antigravity", "google_work", { - installed: false, - models: catalogModels, - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe( - "Install Antigravity in provider settings before sending.", - ); - }); - - it("lets Antigravity check saved credentials when resuming after a restart", () => { - const provider = entry("antigravity", "google_work", { - status: "warning", - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull(); - expect(getAntigravitySendBlockReason(provider, ANTIGRAVITY_DEFAULT_MODEL)).toBeNull(); - expect( - getAntigravitySendBlockReason({ ...provider, models: catalogModels }, "gemini-pro"), - ).toBeNull(); - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - - it("blocks saved model sends until Antigravity loads its account catalog", () => { - expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe( - "Refresh Antigravity models in provider settings before sending.", - ); - }); - - it("blocks an empty Antigravity selection after the catalog has loaded", () => { - const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "")).toBe( - "Choose an Antigravity model before sending.", - ); - }); - it("blocks a saved model that a ready catalog no longer lists", () => { const provider = entry("antigravity", "google_work", { status: "ready", @@ -1623,16 +625,6 @@ describe("resolveComposerProviderSelection", () => { ).toBeNull(); }); - it("keeps existing send behavior for other providers", () => { - const provider = entry("codex", "codex", { - installed: false, - auth: { status: "unknown" }, - models: [], - }).snapshot; - - expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); - }); - it("does not continue an existing Antigravity thread in another profile after deletion", () => { const missingInstanceId = ProviderInstanceId.make("google_work"); const selection = resolveComposerProviderSelection({ @@ -1646,18 +638,6 @@ describe("resolveComposerProviderSelection", () => { expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); }); - it("does not treat the empty draft placeholder as a provider setup target", () => { - const selection = resolveComposerProviderSelection({ - entries: [entry("antigravity", "antigravity", { enabled: false })], - candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId], - lockedProvider: null, - lockedInstanceId: null, - }); - - expect(selection.selectedProviderEntry).toBeUndefined(); - expect(selection.unavailableProviderInstanceId).toBeUndefined(); - }); - it("keeps the session's continuation group when another instance was selected", () => { const sessionEntry = entry("antigravity", "google_work", { enabled: false, @@ -1677,404 +657,13 @@ describe("resolveComposerProviderSelection", () => { }); }); -describe("resolveComposerInteractionMode", () => { - it("resets a restored plan draft when the selected instance does not support plan mode", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: { showInteractionModeToggle: false }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("keeps legacy plan behavior for providers that omit the capability", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: {}, - interactionMode: "plan", - }), - ).toEqual({ enabled: true, interactionMode: "plan" }); - }); - - it("resets a restored plan draft when the beta setting is off", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: false, - provider: { showInteractionModeToggle: true }, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); - - it("disables plan mode until the selected provider is available", () => { - expect( - resolveComposerInteractionMode({ - planModeEnabled: true, - provider: null, - interactionMode: "plan", - }), - ).toEqual({ enabled: false, interactionMode: "default" }); - }); -}); - -describe("resolveBackgroundDraftWorkspaceOptions", () => { - it("keeps New worktree selected without reusing the launched worktree", () => { - expect( - resolveBackgroundDraftWorkspaceOptions({ - envMode: "worktree", - branch: "main", - startFromOrigin: true, - }), - ).toEqual({ - envMode: "worktree", - branch: "main", - worktreePath: null, - startFromOrigin: true, - }); - }); -}); - -describe("proactive completed diff guard", () => { - it.each([ - { files: 0, additions: 0, deletions: 0, action: "ignore" }, - { files: 1, additions: 1, deletions: 0, action: "ignore" }, - { files: 2, additions: 12, deletions: 12, action: "ignore" }, - { files: 1, additions: 25, deletions: 24, action: "ignore" }, - { files: 1, additions: 25, deletions: 25, action: "open" }, - { files: 1, additions: 0, deletions: 50, action: "open" }, - { files: 3, additions: 1, deletions: 0, action: "open" }, - ])( - "uses change size for automatic diffs: $files files, +$additions/-$deletions", - ({ files, additions, deletions, action }) => { - const changedCheckpoint = { - status: "ready", - files: Array.from({ length: files }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions, - deletions, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - }), - ).toBe(action); - }, - ); - - it("waits for definitive checkpoint and repository state", () => { - const missingCheckpoint = { - status: "missing", - files: [], - } satisfies Pick; - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: undefined, - isGitRepo: true, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: missingCheckpoint, - isGitRepo: true, - }), - ).toBe("defer"); - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: undefined, - }), - ).toBe("defer"); - }); - - it("delegates a qualifying completed diff to the store while a pull request is active", () => { - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 50, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - }), - ).toBe("open"); - }); - - it("delegates a qualifying completion to the store while a diff is already open", () => { - const largeCheckpoint = { - status: "ready", - files: Array.from({ length: 3 }, (_, index) => ({ - path: `src/app-${index}.ts`, - kind: "modified" as const, - additions: 20, - deletions: 0, - })), - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: largeCheckpoint, - isGitRepo: true, - }), - ).toBe("open"); - }); -}); - -describe("Diff scope across right-panel tab switches", () => { - const threadRef: ScopedThreadRef = { environmentId, threadId }; - const otherThreadRef: ScopedThreadRef = { environmentId, threadId: ThreadId.make("thread-2") }; - let explicitDiffOpen: ScopedThreadRef | null = null; - - const diffIsActive = (ref: ScopedThreadRef) => - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, ref) === "diff"; - const diffScope = (ref: ScopedThreadRef) => - selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, ref).kind; - const settle = (ref: ScopedThreadRef) => { - const explicit = explicitDiffOpen; - explicitDiffOpen = null; - if (diffIsActive(ref) && explicit !== ref) { - useDiffPanelStore.getState().selectGitScope(ref, "branch"); - } - }; - const clickTab = (ref: ScopedThreadRef, surfaceId: string) => { - const surface = selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - ref, - ).surfaces.find((entry) => entry.id === surfaceId); - explicitDiffOpen = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface!.kind, - diffOpen: diffIsActive(ref), - activeThreadRef: ref, - }); - useRightPanelStore.getState().activateSurface(ref, surfaceId); - settle(ref); - }; - - beforeEach(() => { - explicitDiffOpen = null; - useRightPanelStore.setState({ - byThreadKey: {}, - userActionRevisionByThreadKey: {}, - closeRevisionByThreadKey: {}, - }); - useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - }); - - it("keeps Uncommitted when the user clicks back to the Diff tab", () => { - const panels = useRightPanelStore.getState(); - panels.open(threadRef, "diff"); - settle(threadRef); - useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged"); - - panels.open(threadRef, "files"); - settle(threadRef); - clickTab(threadRef, "diff"); - - expect(diffScope(threadRef)).toBe("unstaged"); - }); - - it("still opens on Changes for generic openings", () => { - const panels = useRightPanelStore.getState(); - const diffStore = useDiffPanelStore.getState(); - - panels.open(threadRef, "diff"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(threadRef, "files"); - settle(threadRef); - panels.closeSurface(threadRef, "files"); - settle(threadRef); - expect(diffScope(threadRef)).toBe("branch"); - - diffStore.selectGitScope(threadRef, "unstaged"); - panels.open(otherThreadRef, "diff"); - diffStore.selectGitScope(otherThreadRef, "unstaged"); - settle(otherThreadRef); - expect(diffScope(otherThreadRef)).toBe("branch"); - }); - - it("marks only a click that brings the Diff tab on screen", () => { - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBe(threadRef); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "diff", - diffOpen: true, - activeThreadRef: threadRef, - }), - ).toBeNull(); - expect( - resolveExplicitDiffOpenOnActivate({ - surfaceKind: "files", - diffOpen: false, - activeThreadRef: threadRef, - }), - ).toBeNull(); - }); -}); - -describe("shouldRefocusComposerOnWindowFocus", () => { - function element( - tagName: string, - options?: { editable?: boolean; role?: string; within?: string }, - ) { - return { - tagName, - isContentEditable: options?.editable ?? false, - getAttribute: (name: string) => (name === "role" ? (options?.role ?? null) : null), - closest: (selector: string) => - options?.within !== undefined && selector.includes(options.within) ? ({} as Element) : null, - }; - } - - it("refocuses when nothing or the body holds focus", () => { - expect(shouldRefocusComposerOnWindowFocus(null)).toBe(true); - expect(shouldRefocusComposerOnWindowFocus(element("BODY"))).toBe(true); - }); - - it("refocuses away from a plain button, such as a pull request tab", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON"))).toBe(true); - }); - - it("leaves other text fields alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("INPUT"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("TEXTAREA"))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { editable: true }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { role: "textbox" }))).toBe(false); - }); - - it.each(["IFRAME", "WEBVIEW"])("leaves a focused %s preview alone", (tagName) => { - expect(shouldRefocusComposerOnWindowFocus(element(tagName))).toBe(false); - }); - - it("leaves a focused device screen alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("DIV", { within: "application" }))).toBe( - false, - ); - }); - - it("leaves a focused terminal alone in the drawer and the right panel", () => { - expect( - shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "data-terminal-owner" })), - ).toBe(false); - }); - - it("leaves focus inside a dialog or popup alone", () => { - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "dialog" }))).toBe(false); - expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); - }); -}); - describe("checkout Git memory", () => { - it("answers from the last status seen for the same checkout", () => { - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); - rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); - expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); - }); - - it("does not answer for a checkout it has not seen", () => { - expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); - expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); - }); - - it("keeps environments apart", () => { - rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); - expect( - recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), - ).toBeUndefined(); - }); - it("does not confuse an environment id containing the separator with a path", () => { rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); }); }); -describe("threadShellHasStarted", () => { - it("counts a thread that has a user message but no latest turn", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: now, runtime: null }), - ).toBe(true); - }); - - it("counts a thread with a live runtime and nothing else", () => { - expect( - threadShellHasStarted({ - latestRun: null, - latestUserMessageAt: null, - runtime: { - providerInstanceId: ProviderInstanceId.make("codex"), - status: "starting", - providerName: "codex", - activeRunId: null, - lastError: null, - updatedAt: now, - }, - }), - ).toBe(true); - }); - - it("does not count a thread that never sent anything", () => { - expect( - threadShellHasStarted({ latestRun: null, latestUserMessageAt: null, runtime: null }), - ).toBe(false); - expect(threadShellHasStarted(null)).toBe(false); - }); -}); - -it("follows a changed server PR link without replacing an unrelated open panel", () => { - const previous = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const current = { - ...previous, - number: 43, - url: "https://github.com/pingdotgg/t3code/pull/43", - }; - const surface = { - id: "pull-request:previous", - kind: "pull-request", - projectId: previous.projectId, - repository: "PingDotGG/T3Code", - number: previous.number, - } satisfies RightPanelSurface; - - expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); - expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); - expect(shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 })).toBe( - false, - ); - expect( - shouldRetargetThreadPullRequestPanel(previous, current, { - ...surface, - projectId: "another-project", - }), - ).toBe(false); -}); - describe("worktree setup visibility", () => { const stage = ( id: "fetch" | "checkout" | "submodules" | "setup-script" | "agent", @@ -2108,30 +697,6 @@ describe("worktree setup visibility", () => { stages: [stage("checkout", "done"), stage("setup-script", "done"), stage("agent", "done")], }; - it("keeps setup presentation continuous until the provider handoff", () => { - const progress = ( - localPreparing: boolean, - runStatus: NonNullable["status"] | undefined, - latest: WorktreeSetupSnapshot | null, - held: WorktreeSetupSnapshot | null = null, - ) => resolveWorktreeSetupProgress({ threadId, localPreparing, runStatus, latest, held }); - - expect(progress(true, undefined, null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", null).isPreparingWorktree).toBe(true); - expect(progress(false, "preparing", base).snapshot).toBe(base); - expect(progress(false, "starting", base).isPreparingWorktree).toBe(true); - const handedOff = { - ...base, - sequence: 2, - stages: [stage("setup-script", "running"), stage("agent", "done")], - }; - expect(progress(false, "starting", handedOff, base)).toEqual({ - snapshot: handedOff, - isPreparingWorktree: false, - }); - expect(progress(false, "running", null, handedOff).snapshot).toBe(handedOff); - }); - it("uses streamed setup progress immediately without reverting to an older held snapshot", () => { const newest = { ...settledDone, sequence: 9 }; const resolve = (latest: WorktreeSetupSnapshot | null, held: WorktreeSetupSnapshot | null) => @@ -2148,22 +713,6 @@ describe("worktree setup visibility", () => { expect(resolve(other, other)).toEqual({ snapshot: null, isPreparingWorktree: false }); }); - it.each(["failed", "cancelled"] as const)( - "does not keep %s setup in the preparing state", - (phase) => { - const snapshot = { ...base, phase }; - expect( - resolveWorktreeSetupProgress({ - threadId, - localPreparing: false, - runStatus: "failed", - latest: snapshot, - held: base, - }), - ).toEqual({ snapshot, isPreparingWorktree: false }); - }, - ); - it("reads the settled snapshot back from the thread's activities", () => { const activities = [ { kind: "setup-script.started", payload: {} }, @@ -2173,69 +722,6 @@ describe("worktree setup visibility", () => { expect(findRecordedWorktreeSetup(activities, threadId)).toEqual(settledDone); expect(findRecordedWorktreeSetup(activities, ThreadId.make("other"))).toBeNull(); }); - - it("shows a running setup and drops a clean one once the turn started", () => { - const visible = (snapshot: WorktreeSetupSnapshot | null, turnStarted: boolean) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted, - followUpSent: false, - }); - expect( - resolveVisibleWorktreeSetup({ - live: base, - recorded: null, - turnStarted: false, - followUpSent: false, - }), - ).toEqual(base); - expect(visible(settledDone, false)).toEqual(settledDone); - expect(visible(settledDone, true)).toBeNull(); - expect(visible(null, true)).toBeNull(); - }); - - it("keeps a failed script, a failed setup, and a cancelled setup visible", () => { - const scriptFailed = { - ...settledDone, - stages: [stage("checkout", "done"), stage("setup-script", "failed"), stage("agent", "done")], - }; - const visible = (snapshot: WorktreeSetupSnapshot, followUpSent = false) => - resolveVisibleWorktreeSetup({ - live: null, - recorded: snapshot, - turnStarted: true, - followUpSent, - }); - expect(visible(scriptFailed)).toEqual(scriptFailed); - const failed = { ...settledDone, phase: "failed" as const, error: "git exploded" }; - expect(visible(failed)).toEqual(failed); - const cancelled = { ...settledDone, phase: "cancelled" as const }; - expect(visible(cancelled)).toEqual(cancelled); - - expect(visible(scriptFailed, true)).toBeNull(); - expect(visible(failed, true)).toBeNull(); - expect(visible(cancelled, true)).toBeNull(); - expect(visible(settledDone, true)).toBeNull(); - const stillRunning = { - ...base, - stages: [stage("checkout", "done"), stage("setup-script", "running"), stage("agent", "done")], - }; - expect(visible(stillRunning, true)).toEqual(stillRunning); - }); - - it("prefers whichever snapshot is newer by sequence", () => { - const pick = (live: WorktreeSetupSnapshot | null, recorded: WorktreeSetupSnapshot | null) => - resolveVisibleWorktreeSetup({ live, recorded, turnStarted: false, followUpSent: false }); - expect(pick({ ...base, sequence: 3 }, { ...settledDone, sequence: 7 })).toEqual({ - ...settledDone, - sequence: 7, - }); - expect(pick({ ...settledDone, sequence: 9 }, { ...base, sequence: 1 })).toEqual({ - ...settledDone, - sequence: 9, - }); - }); }); describe("waitForRevertedMessage", () => { @@ -2503,25 +989,6 @@ describe("waitForRevertedMessage", () => { vi.useRealTimers(); } }); - - it("ignores a failure recorded for an earlier rollback", async () => { - vi.useFakeTimers(); - const { state, projection } = projectionAtom(); - const waiting = waitForRevertedMessage(threadRef, 1, requestId, async () => {}, 50); - const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind."); - appAtomRegistry.set(state, { - data: Option.some({ - ...projection, - thread: { - ...projection.thread, - rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." }, - }, - }), - }); - await vi.advanceTimersByTimeAsync(50); - await settled; - vi.useRealTimers(); - }); }); describe("mergeDirectPreviewAnnotation", () => { @@ -2546,17 +1013,6 @@ describe("mergeDirectPreviewAnnotation", () => { const image = (id: string) => ({ id, type: "image", name: `${id}.png`, mimeType: "image/png", sizeBytes: 1 }) as never; - it("appends the screenshot and claims the crop when the turn has room", () => { - const result = mergeDirectPreviewAnnotation({ - images: [], - fileCount: 0, - previewAnnotations: [], - direct: { annotation: annotation("a"), image: image("crop") }, - }); - expect(result.images.map((entry) => entry.id)).toEqual(["crop"]); - expect(result.previewAnnotations[0]?.screenshot?.dataUrl).toBe(""); - }); - it("drops the screenshot claim when the attachment cap is full", () => { const result = mergeDirectPreviewAnnotation({ images: [], @@ -2567,41 +1023,4 @@ describe("mergeDirectPreviewAnnotation", () => { expect(result.images).toEqual([]); expect(result.previewAnnotations[0]?.screenshot).toBeNull(); }); - - it("keeps an annotation already in the draft as it is", () => { - const existing = [annotation("a")]; - const result = mergeDirectPreviewAnnotation({ - images: [], - fileCount: 0, - previewAnnotations: existing, - direct: { annotation: annotation("a"), image: null }, - }); - expect(result.previewAnnotations).toBe(existing); - }); -}); - -describe("isThreadWokeUnseen", () => { - const base = { - wokeAt: "2026-10-16T10:00:00.000Z", - settled: false, - lastVisitedAt: null, - latestRunCompletedAt: null, - }; - - it("shows a wake the user has not seen", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T09:00:00.000Z" })).toBe(true); - expect(isThreadWokeUnseen(base)).toBe(true); - }); - - it("hides a wake seen by a later visit or a later completed run", () => { - expect(isThreadWokeUnseen({ ...base, lastVisitedAt: "2026-10-16T11:00:00.000Z" })).toBe(false); - expect(isThreadWokeUnseen({ ...base, latestRunCompletedAt: "2026-10-16T11:00:00.000Z" })).toBe( - false, - ); - }); - - it("hides settled threads and unparseable wake times", () => { - expect(isThreadWokeUnseen({ ...base, settled: true })).toBe(false); - expect(isThreadWokeUnseen({ ...base, wokeAt: "not a date" })).toBe(false); - }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 03a29ceb7406..36f0feda2846 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -71,7 +71,7 @@ import { export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; -export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export function agentControlledBrowserCloseConfirmation( surfaces: readonly RightPanelSurface[], @@ -223,15 +223,6 @@ export function codexArtifactTemplatePromptToAppend( export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); -export function resolveEffectiveInteractionMode(input: { - planModeEnabled: boolean; - composerInteractionMode: ProviderInteractionMode | null; - threadInteractionMode: ProviderInteractionMode | null | undefined; -}): ProviderInteractionMode { - if (!input.planModeEnabled) return "default"; - return input.composerInteractionMode ?? input.threadInteractionMode ?? "default"; -} - export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; @@ -474,6 +465,7 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { }): { modelSelection?: ModelSelection; branch?: string; + worktreePath?: null; } | null { const nextModelSelection = input.nextModelSelection; const modelSelectionChanged = @@ -488,7 +480,7 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { } return { ...(modelSelectionChanged ? { modelSelection: nextModelSelection } : {}), - ...(branchChanged ? { branch: input.nextBranch } : {}), + ...(branchChanged ? { branch: input.nextBranch, worktreePath: null } : {}), }; } @@ -531,25 +523,6 @@ export function buildLocalDraftThread( }); } -export function shouldWriteThreadErrorToCurrentServerThread(input: { - serverThread: - | { - environmentId: EnvironmentId; - id: ThreadId; - } - | null - | undefined; - routeThreadRef: ScopedThreadRef; - targetThreadId: ThreadId; -}): boolean { - return Boolean( - input.serverThread && - input.targetThreadId === input.routeThreadRef.threadId && - input.serverThread.environmentId === input.routeThreadRef.environmentId && - input.serverThread.id === input.targetThreadId, - ); -} - export function resolveComposerProviderSelection(input: { entries: ReadonlyArray; candidateInstanceIds: ReadonlyArray; @@ -796,12 +769,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ export { readFileAsDataUrl } from "../lib/imageCompression"; -export function resolveSendEnvMode(input: { - requestedEnvMode: DraftThreadEnvMode; - isGitRepo: boolean; -}): DraftThreadEnvMode { - return input.isGitRepo ? input.requestedEnvMode : "local"; -} +export { resolveSendEnvMode } from "@t3tools/shared/projectSettings"; export function resolveBackgroundDraftWorkspaceOptions(input: { envMode: DraftThreadEnvMode; @@ -1366,21 +1334,4 @@ export function mergeDirectPreviewAnnotation(input: { return { images, previewAnnotations }; } -/** - * Whether a woken thread woke after the user last saw it. A run that completed - * after the last visit counts as seeing it, since the user watched it finish. - */ -export function isThreadWokeUnseen(input: { - readonly wokeAt: string | null; - readonly settled: boolean; - readonly lastVisitedAt: string | null | undefined; - readonly latestRunCompletedAt: string | null; -}): boolean { - if (input.wokeAt === null || input.settled) return false; - const wokeAtMs = Date.parse(input.wokeAt); - if (Number.isNaN(wokeAtMs)) return false; - const seenAt = [input.lastVisitedAt, input.latestRunCompletedAt] - .map((value) => (value ? Date.parse(value) : NaN)) - .filter((ms) => !Number.isNaN(ms)); - return seenAt.every((ms) => ms < wokeAtMs); -} +export { isThreadWokeUnseen } from "@t3tools/client-runtime/state/thread-settled"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ef36ced2f8f3..13cc09e6fc18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -114,6 +114,7 @@ import { threadForkPointBeforeMessage, } from "@t3tools/client-runtime/thread-tabs"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { declinePendingApprovals } from "@t3tools/client-runtime/state/thread-requests"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads"; import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; @@ -432,6 +433,7 @@ import { vcsEnvironment } from "../state/vcs"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useProjectClone } from "../state/projectClones"; import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; +import { dismissProjectFileCopy, useProjectFileCopy } from "../lib/addProjectToMachine"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -508,7 +510,7 @@ import { useWorkingAgentCount } from "./chat/AgentsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, - resolveCheckoutBranchMismatch, + resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -627,10 +629,14 @@ import { ServerUpdateAction, serverUpdateStageLabel } from "./ServerUpdateAction import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; import { ComposerServerUpdateIcon } from "./chat/ComposerServerUpdateStatus"; import { + buildVersionMismatchDismissalKey, dismissServerUpdateFailure, + dismissVersionMismatch, isServerUpdateFailureDismissed, + isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, + serverUpdateGuidance, supportsDesktopAppUpdate, supportsServerUpdateThreadContinuation, } from "../versionSkew"; @@ -1586,7 +1592,6 @@ export default function ChatView(props: ChatViewProps) { const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerTimelineInset, setComposerTimelineInset] = useState(0); - const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerTimelineInsetRef = useRef(0); const [composerReservedHeight, setComposerReservedHeight] = useState(0); const composerReservedHeightRef = useRef(0); @@ -2092,15 +2097,26 @@ export default function ChatView(props: ChatViewProps) { } }; const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); + const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); const projectCloneSendBlockReason = - activeProjectClone === null - ? null - : activeProjectClone.phase === "running" - ? "Cloning repository" - : activeProjectClone.phase === "done" - ? null - : "Repository not cloned"; + activeProjectClone?.phase === "running" + ? "Cloning repository" + : activeProjectClone !== null && activeProjectClone.phase !== "done" + ? "Repository not cloned" + : activeProjectFileCopy?.phase === "copying" + ? "Copying files" + : null; const projectCloneBannerItem = (() => { + if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { + return { + id: `project-file-copy:${activeProjectRef.projectId}`, + variant: "error", + icon: , + title: activeProjectFileCopy.title, + description: activeProjectFileCopy.failures.join("\n"), + onDismiss: () => dismissProjectFileCopy(activeProjectRef), + }; + } if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { return null; } @@ -2324,6 +2340,10 @@ export default function ChatView(props: ChatViewProps) { logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; @@ -2479,6 +2499,18 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_AUTO_UPDATE_ENVIRONMENTS; const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); + const versionMismatchDismissKey = + versionMismatch && activeThread + ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) + : null; + const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( + null, + ); + const versionMismatchDismissed = + versionMismatchDismissKey === dismissedVersionMismatchKey || + isVersionMismatchDismissed(versionMismatchDismissKey); + const showVersionMismatchBanner = + versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; const hasMultipleRegisteredEnvironments = environments.length > 1; const versionMismatchServerLabel = hasMultipleRegisteredEnvironments && activeThread @@ -2498,30 +2530,6 @@ export default function ChatView(props: ChatViewProps) { const serverUpdateFailureDismissed = serverUpdateState === dismissedServerUpdateState || isServerUpdateFailureDismissed(serverUpdateState); - const activeServerUpdate = - !automaticEnvironment && - serverUpdateEnvironmentId && - versionMismatch && - serverUpdateState.status === "idle" - ? { - environmentId: serverUpdateEnvironmentId, - serverLabel: - activeEnvironmentOption?.label ?? - serverConfig?.environment.label ?? - versionMismatchServerLabel, - serverVersion: versionMismatch.serverVersion, - targetVersion: versionMismatch.clientVersion, - selfUpdate: versionMismatchSelfUpdate, - installation: versionMismatchInstallation, - desktopAppUpdate: versionMismatchDesktopAppUpdate, - threadContinuation: versionMismatchThreadContinuation, - } - : null; - const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ - activeEnvironment: activeEnvironmentOption, - canPickEnvironment: hasMultipleEnvironments, - serverUpdateAvailable: activeServerUpdate !== null, - }); const systemComposerBannerItems = (() => { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; @@ -2580,8 +2588,9 @@ export default function ChatView(props: ChatViewProps) { if ( !automaticEnvironment && serverUpdateEnvironmentId && - serverUpdateState.status !== "idle" && - !serverUpdateFailureDismissed + (serverUpdateState.status === "idle" + ? showVersionMismatchBanner + : !serverUpdateFailureDismissed) ) { const updateInProgress = serverUpdateState.status === "running"; const updateFailed = serverUpdateState.status === "failed"; @@ -2590,11 +2599,39 @@ export default function ChatView(props: ChatViewProps) { variant: updateFailed ? "error" : "default", priority: updateInProgress ? "urgent" : "notice", icon: , - title: `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`, + title: + updateInProgress || updateFailed ? ( + `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}` + ) : versionMismatch ? ( + + + Server update available + + } + /> + + {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "} + {versionMismatch.clientVersion} + + + ) : ( + "Server update available" + ), description: serverUpdateState.status === "failed" ? serverUpdateState.message - : serverUpdateStageLabel(serverUpdateState.stage), + : serverUpdateState.status === "running" + ? serverUpdateStageLabel(serverUpdateState.stage) + : versionMismatchSelfUpdate !== null && + (versionMismatchSelfUpdate !== "desktop-managed" || + !versionMismatchDesktopAppUpdate) + ? serverUpdateGuidance(versionMismatchSelfUpdate) + : undefined, actions: updateInProgress ? ( disconnectAction ) : !versionMismatch || @@ -2612,15 +2649,19 @@ export default function ChatView(props: ChatViewProps) { variant="ghost" /> ), - ...(updateFailed - ? { + ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) + ? {} + : { dismissLabel: "Dismiss update notice", onDismiss: () => { - dismissServerUpdateFailure(serverUpdateState); - setDismissedServerUpdateState(serverUpdateState); + if (updateFailed) { + dismissServerUpdateFailure(serverUpdateState); + setDismissedServerUpdateState(serverUpdateState); + } + dismissVersionMismatch(versionMismatchDismissKey); + setDismissedVersionMismatchKey(versionMismatchDismissKey); }, - } - : {}), + }), }); } if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); @@ -2705,6 +2746,13 @@ export default function ChatView(props: ChatViewProps) { composerRef, setThreadError, }); + // A send awaits uploads before it declines, so it reads the approvals pending by then. + const pendingApprovalsRef = useRef(pendingApprovals); + const respondingRequestIdsRef = useRef(respondingRequestIds); + useLayoutEffect(() => { + pendingApprovalsRef.current = pendingApprovals; + respondingRequestIdsRef.current = respondingRequestIds; + }, [pendingApprovals, respondingRequestIds]); const activeProposedPlan = (() => { if (!latestRunSettled) { return null; @@ -3399,7 +3447,6 @@ export default function ChatView(props: ChatViewProps) { hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - serverUpdateAvailable: activeServerUpdate !== null, hostsRestingComposerControls: routeKind === "server", }); const showComposerContextStrip = shouldShowComposerContextStrip({ @@ -3408,7 +3455,6 @@ export default function ChatView(props: ChatViewProps) { hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - serverUpdateAvailable: activeServerUpdate !== null, hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); const mountComposerModelStrip = @@ -5131,8 +5177,8 @@ export default function ChatView(props: ChatViewProps) { ? "worktree" : "thread" : null; - const checkoutBranchMismatch = isServerThread - ? resolveCheckoutBranchMismatch({ + const localCheckoutBranchMismatch = isServerThread + ? resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: envMode, activeWorktreePath, activeThreadBranch, @@ -5142,7 +5188,6 @@ export default function ChatView(props: ChatViewProps) { const publishComposerOverlayHeight = (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - setComposerOverlayHeight(isDraftHeroState ? 0 : nextHeight); const isResting = overlayComposerIsResting({ composerMounted: composerMountedRef.current, composerReportedResting: composerRestingRef.current, @@ -5458,10 +5503,10 @@ export default function ChatView(props: ChatViewProps) { const nowMinute = useNowMinute(); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, - checkoutBranchMismatch, + localCheckoutBranchMismatch, ); const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ - hasMismatch: checkoutBranchMismatch !== null, + hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), composerHasContent: composerHasDraftContent, wasShownForCurrentMismatch: @@ -5480,7 +5525,7 @@ export default function ChatView(props: ChatViewProps) { if ( !activeProjectCwd || !activeThread || - !checkoutBranchMismatch?.canRestoreThreadBranch || + !localCheckoutBranchMismatch || isRestoringThreadBranch ) { return; @@ -5490,7 +5535,7 @@ export default function ChatView(props: ChatViewProps) { environmentId, input: { cwd: activeProjectCwd, - refName: checkoutBranchMismatch.threadBranch, + refName: localCheckoutBranchMismatch.threadBranch, }, }); if (checkoutResult._tag === "Failure") { @@ -5510,7 +5555,7 @@ export default function ChatView(props: ChatViewProps) { setIsRestoringThreadBranch(false); return; } - const nextBranch = checkoutResult.value.refName ?? checkoutBranchMismatch.threadBranch; + const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; if (nextBranch !== activeThread.branch) { const updateResult = await updateThreadMetadata({ environmentId, @@ -5634,7 +5679,7 @@ export default function ChatView(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: activeThreadId, createdAt: new Date().toISOString(), - ...(checkoutBranchMismatch ? { branch: checkoutBranchMismatch.currentBranch } : {}), + ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, interactionMode, }); @@ -5663,6 +5708,7 @@ export default function ChatView(props: ChatViewProps) { failureMessage: string, dispatchMode?: ComposerDispatchMode, ): Promise => { + if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return false; if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return false; const context = composerRef.current?.getSendContext(); if (!context?.providerAvailable) return false; @@ -5695,7 +5741,7 @@ export default function ChatView(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId, createdAt, - ...(checkoutBranchMismatch ? { branch: checkoutBranchMismatch.currentBranch } : {}), + ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, interactionMode: context.interactionMode, }); @@ -5792,7 +5838,6 @@ export default function ChatView(props: ChatViewProps) { /> ), title: presentation.title, - descriptionPlacement: "below", description: presentation.items.length === 1 && presentation.items[0]?.childThreadId === undefined ? undefined @@ -5808,7 +5853,7 @@ export default function ChatView(props: ChatViewProps) { {separator} ) : ( - + - {checkoutBranchMismatch.threadBranch} + {localCheckoutBranchMismatch.threadBranch} } /> - This thread last ran on {checkoutBranchMismatch.threadBranch}. Sending will continue - on {checkoutBranchMismatch.currentBranch}. + This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will + continue on {localCheckoutBranchMismatch.currentBranch}. ), - actions: checkoutBranchMismatch.canRestoreThreadBranch ? ( + actions: ( - ) : null, + ), dismissLabel: "Dismiss branch change notice", onDismiss: () => { dismissBranchMismatchForSession(activeBranchMismatchKey); @@ -6863,7 +6908,9 @@ export default function ChatView(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId, createdAt, - ...(checkoutBranchMismatch ? { branch: checkoutBranchMismatch.currentBranch } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode, }); @@ -7154,6 +7201,9 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); + // What the user pressed send on; uploads below can take a while, and a redirect must not + // decline approvals for a draft that changed meanwhile. + const promptAtSend = promptRef.current; if ( usageLimitsOffered && usageLimitsKey !== null && @@ -7715,6 +7765,94 @@ export default function ChatView(props: ChatViewProps) { ); return; } + // A message sent while approvals are pending redirects the agent. They are declined only + // here, after everything above has accepted the send, so local commands and rejected + // drafts leave them alone. After navigating away during an upload, the shown approvals + // are another thread's, so this send leaves them alone too. + const approvalsToDecline = + currentRouteThreadKeyRef.current === routeThreadKey + ? pendingApprovalsRef.current.filter((approval) => + sendCtx?.shownPendingApprovalIds.includes(approval.requestId), + ) + : []; + if (approvalsToDecline.length > 0) { + // A draft edited since send, or a paste still landing in it, is no longer the message + // the user sent. + const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); + // A direct annotation send writes its image and reference into the draft as it sends, + // so the live draft may still lack them; anything else differing is a real edit. + const annotationImageId = directAnnotation?.image?.id; + const attachmentIdsBeforeAnnotation = attachmentIdsAtSend.filter( + (id) => id !== annotationImageId, + ); + const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => + left.length === right.length && left.every((id, index) => id === right[index]); + const draftChangedSinceSend = () => { + if (composerRef.current?.hasPendingAttachments() === true) return true; + const sendContext = composerRef.current?.getSendContext(); + const attachmentIds = sendContext + ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) + : attachmentIdsAtSend; + const promptUnchanged = + promptRef.current === promptAtSend || promptRef.current === promptForSend; + const attachmentsUnchanged = + sameIds(attachmentIds, attachmentIdsAtSend) || + (annotationImageId !== undefined && + sameIds(attachmentIds, attachmentIdsBeforeAnnotation)); + return !promptUnchanged || !attachmentsUnchanged; + }; + const draftBeforeDecline = useComposerDraftStore + .getState() + .getComposerDraft(composerDraftTarget); + const changedBeforeDecline = draftChangedSinceSend(); + const pendingIdsBeforeDecline = pendingApprovalsRef.current.map( + (approval) => approval.requestId, + ); + const approvalResponding = approvalsToDecline.some((approval) => + respondingRequestIdsRef.current.includes(approval.requestId), + ); + const declined = + !changedBeforeDecline && + !approvalResponding && + (await declinePendingApprovals(approvalsToDecline, onRespondToApprovalStable).catch( + () => false, + )); + const draftChanged = + changedBeforeDecline || + draftChangedSinceSend() || + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) !== + draftBeforeDecline; + // An approval that arrived during the decline would block the redirect; keep the draft so + // the next send declines it too. + const approvalArrived = pendingApprovalsRef.current.some( + (approval) => !pendingIdsBeforeDecline.includes(approval.requestId), + ); + if ( + !declined || + draftChanged || + approvalArrived || + currentRouteThreadKeyRef.current !== routeThreadKey + ) { + sendInFlightRef.current = false; + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + if (draftChanged || approvalResponding || (declined && approvalArrived)) { + toastManager.add({ + type: "info", + title: draftChanged + ? declined + ? "Approval declined. Your message changed, so it wasn't sent." + : "Your message changed, so it wasn't sent." + : approvalResponding + ? "An approval is already being answered, so your message wasn't sent." + : "Another approval arrived, so your message wasn't sent.", + description: "Send again when it's ready.", + }); + } + return; + } + } if (sendQueuesBehindSetup) { setSetupQueueSendThreadKey(activeThreadKey); } else { @@ -8055,7 +8193,9 @@ export default function ChatView(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(checkoutBranchMismatch ? { branch: checkoutBranchMismatch.currentBranch } : {}), + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), runtimeMode, interactionMode: sendInteractionMode, }); @@ -8401,7 +8541,7 @@ export default function ChatView(props: ChatViewProps) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, - ...(checkoutBranchMismatch ? { branch: checkoutBranchMismatch.currentBranch } : {}), + ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, interactionMode: nextInteractionMode, }); @@ -8512,7 +8652,7 @@ export default function ChatView(props: ChatViewProps) { modelSelection: nextThreadModelSelection, runtimeMode: defaultRuntimeMode, interactionMode: "default", - branch: checkoutBranchMismatch?.currentBranch ?? activeThreadBranch, + branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }, @@ -9313,7 +9453,6 @@ export default function ChatView(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} - composerOverlayHeight={isDraftHeroState ? 0 : composerOverlayHeight} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} @@ -9696,7 +9835,6 @@ export default function ChatView(props: ChatViewProps) { availableEnvironments={logicalProjectEnvironments} composerControlsHostRef={setRestingComposerControlsHost} contextStripVisible={showComposerContextStrip} - serverUpdate={activeServerUpdate} />
)} @@ -9728,7 +9866,7 @@ export default function ChatView(props: ChatViewProps) { diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 667a75186100..f056ba2f7c7f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,23 +1,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import type { Project, Thread } from "../types"; +import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { - buildBrowseGroups, - buildCommandPaletteProjectMetadata, - buildCommandPaletteRows, - buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, - enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, - findHighlightedCommandPaletteItem, reduceCommandPaletteUiState, createCommandPaletteSearchState, reduceCommandPaletteSearchState, - type CommandPaletteActionItem, - type CommandPaletteGroup, type CommandPaletteUiState, } from "./CommandPalette.logic"; @@ -63,124 +55,6 @@ describe("linked pull request thread navigation", () => { }); }); -describe("buildCommandPaletteProjectMetadata", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-build-box"); - const locations = new Map([ - [localEnvironmentId, { kind: "local" as const, label: "Local", machine: "laptop" as const }], - [ - remoteEnvironmentId, - { kind: "remote" as const, label: "Build box", machine: "server" as const }, - ], - ]); - - it("makes every member environment and path searchable", () => { - const metadata = buildCommandPaletteProjectMetadata({ - projects: [ - { - environmentId: localEnvironmentId, - title: "T3 Code", - workspaceRoot: "/Users/theo/Projects/t3code", - }, - { - environmentId: remoteEnvironmentId, - title: "t3code", - workspaceRoot: "/srv/t3code", - }, - ], - locationByEnvironmentId: locations, - }); - - expect(metadata.searchTerms).toEqual([ - "T3 Code", - "/Users/theo/Projects/t3code", - "Local", - "t3code", - "/srv/t3code", - "Build box", - ]); - expect(metadata.environmentLabels).toEqual(["Local", "Build box"]); - - const [filteredGroup] = filterCommandPaletteGroups({ - activeGroups: [], - query: "build box", - isInSubmenu: false, - projectSearchItems: [ - { - kind: "action", - value: "project:t3code", - title: "T3 Code", - searchTerms: metadata.searchTerms, - icon: null, - run: async () => undefined, - }, - ], - threadSearchItems: [], - }); - expect(filteredGroup?.items).toHaveLength(1); - }); - - it("deduplicates grouped checkouts by environment", () => { - const metadata = buildCommandPaletteProjectMetadata({ - projects: [ - { - environmentId: remoteEnvironmentId, - title: "T3 Code", - workspaceRoot: "/srv/t3code", - }, - { - environmentId: remoteEnvironmentId, - title: "T3 Code worktree", - workspaceRoot: "/srv/t3code-feature", - }, - ], - locationByEnvironmentId: locations, - }); - - expect(metadata.environmentLabels).toEqual(["Build box"]); - }); - - it("deduplicates distinct environments with the same label", () => { - const secondRemoteEnvironmentId = EnvironmentId.make("environment-build-box-2"); - const metadata = buildCommandPaletteProjectMetadata({ - projects: [ - { - environmentId: remoteEnvironmentId, - title: "T3 Code", - workspaceRoot: "/srv/t3code", - }, - { - environmentId: secondRemoteEnvironmentId, - title: "T3 Code mirror", - workspaceRoot: "/srv/mirror/t3code", - }, - ], - locationByEnvironmentId: new Map([ - [remoteEnvironmentId, { label: "Build box" }], - [secondRemoteEnvironmentId, { label: "Build box" }], - ]), - }); - - expect(metadata.environmentLabels).toEqual(["Build box"]); - }); - - it("uses a human-readable fallback when presentation data is unavailable", () => { - const metadata = buildCommandPaletteProjectMetadata({ - projects: [ - { - environmentId: remoteEnvironmentId, - title: "T3 Code", - workspaceRoot: "/srv/t3code", - }, - ], - locationByEnvironmentId: new Map(), - }); - - expect(metadata.searchTerms).toContain("Remote"); - expect(metadata.environmentLabels).toEqual(["Remote"]); - }); -}); - describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -260,87 +134,11 @@ describe("reduceCommandPaletteUiState", () => { reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, ).toBeNull(); }); - - it("routes open intents to command mode", () => { - const filesOpen = reduceCommandPaletteUiState(closedState, { - _tag: "ToggleMode", - mode: "files", - }); - expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenAddProject" })).toEqual({ - open: true, - mode: "command", - openIntent: { kind: "add-project" }, - }); - expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenNewThreadIn" })).toEqual({ - open: true, - mode: "command", - openIntent: { kind: "new-thread-in" }, - }); - }); - - it("preserves the mode on close and resets it on open", () => { - const filesOpen = reduceCommandPaletteUiState(closedState, { - _tag: "ToggleMode", - mode: "files", - }); - - expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: false })).toEqual({ - open: false, - mode: "files", - openIntent: null, - }); - expect(reduceCommandPaletteUiState(filesOpen, { _tag: "SetOpen", open: true })).toEqual({ - open: true, - mode: "command", - openIntent: null, - }); - }); -}); - -describe("enumerateCommandPaletteItems", () => { - it("assigns positional jump shortcuts to the first nine displayed items", () => { - const items = Array.from({ length: 10 }, (_, index) => ({ - kind: "action" as const, - value: `project-${index + 1}`, - searchTerms: [], - title: `Project ${index + 1}`, - icon: null, - shortcutCommand: "chat.new" as const, - run: async () => undefined, - })); - - expect(enumerateCommandPaletteItems(items).map((item) => item.shortcutCommand)).toEqual([ - "thread.jump.1", - "thread.jump.2", - "thread.jump.3", - "thread.jump.4", - "thread.jump.5", - "thread.jump.6", - "thread.jump.7", - "thread.jump.8", - "thread.jump.9", - undefined, - ]); - }); }); const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); -function makeProject(overrides: Partial = {}): Project { - return { - id: PROJECT_ID, - environmentId: LOCAL_ENVIRONMENT_ID, - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - ...overrides, - }; -} - function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ id: ThreadId.make("thread-1"), @@ -366,90 +164,7 @@ function makeThread(overrides: Partial = {}): Thread { }); } -describe("buildProjectActionItems", () => { - it("shows offline checkout rows but excludes them from keyboard selection", () => { - const local = makeProject(); - const remote = makeProject({ - id: local.id, - environmentId: EnvironmentId.make("offline"), - }); - const items = buildProjectActionItems({ - projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), - valuePrefix: "new-thread-in", - icon: () => null, - runProject: async () => undefined, - disabled: (project) => project.environmentId === remote.environmentId, - }); - const groups = [{ value: "projects", label: "Projects", items }]; - const { rows, itemValues } = buildCommandPaletteRows(groups); - - expect(rows.filter((row) => row.kind === "item").map((row) => row.item.value)).toEqual([ - `new-thread-in:${remote.environmentId}:${remote.id}`, - `new-thread-in:${local.environmentId}:${local.id}`, - ]); - expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]); - expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull(); - expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]); - }); - - it("shows the grouped display name but keeps the real title for icons", () => { - const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); - const iconTitles: string[] = []; - const [item] = buildProjectActionItems({ - projects: [{ ...project, displayName: "t3dotgg/fleet" }], - valuePrefix: "project", - icon: (candidate) => { - iconTitles.push(candidate.title); - return null; - }, - runProject: async () => undefined, - }); - - expect(item?.title).toBe("t3dotgg/fleet"); - expect(item?.searchTerms).toEqual( - expect.arrayContaining(["t3dotgg/fleet", "fleet", "/Users/theo/Code/p/fleet"]), - ); - expect(iconTitles).toEqual(["fleet"]); - }); -}); - describe("buildThreadActionItems", () => { - it("orders threads by most recent activity and formats timestamps from updatedAt", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); - - try { - const items = buildThreadActionItems({ - threads: [ - makeThread({ - id: ThreadId.make("thread-older"), - title: "Older thread", - updatedAt: "2026-03-24T12:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("thread-newer"), - title: "Newer thread", - createdAt: "2026-03-20T00:00:00.000Z", - updatedAt: "2026-03-20T00:00:00.000Z", - }), - ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), - sortOrder: "updated_at", - icon: null, - runThread: async (_thread) => undefined, - }); - - expect(items.map((item) => item.value)).toEqual([ - "thread:thread-older", - "thread:thread-newer", - ]); - expect(items[0]?.timestamp).toBe("1d ago"); - expect(items[1]?.timestamp).toBe("5d ago"); - } finally { - vi.useRealTimers(); - } - }); - it("ranks thread title matches ahead of contextual project-name matches", () => { const threadItems = buildThreadActionItems({ threads: [ @@ -535,35 +250,6 @@ describe("buildThreadActionItems", () => { ]); }); - it("preserves thread project-name matches when there is no stronger title match", () => { - const group: CommandPaletteGroup = { - value: "threads-search", - label: "Threads", - items: [ - { - kind: "action", - value: "thread:project-context-only", - searchTerms: ["Fix navbar spacing", "Project"], - title: "Fix navbar spacing", - description: "Project", - icon: null, - run: async () => undefined, - }, - ], - }; - - const groups = filterCommandPaletteGroups({ - activeGroups: [group], - query: "project", - isInSubmenu: false, - projectSearchItems: [], - threadSearchItems: [], - }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); - }); - it("ranks an order-independent setting title match above a split context match", () => { const settingsSearchItems = [ { @@ -601,28 +287,6 @@ describe("buildThreadActionItems", () => { ]); }); - it("keeps accent-insensitive setting results", () => { - const groups = filterCommandPaletteGroups({ - activeGroups: [], - query: "thè\u{1ab0}mes", - isInSubmenu: false, - projectSearchItems: [], - settingsSearchItems: [ - { - kind: "action", - value: "setting:theme", - searchTerms: ["Themes", "Appearance"], - title: "Themes", - icon: null, - run: async () => undefined, - }, - ], - threadSearchItems: [], - }); - - expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]); - }); - it("normalizes case independently of the host locale", () => { const toLocaleLowerCase = String.prototype.toLocaleLowerCase; const localeLowerCase = vi @@ -656,29 +320,6 @@ describe("buildThreadActionItems", () => { } }); - it("keeps message excerpts searchable without replacing thread metadata", () => { - const [item] = buildThreadActionItems({ - threads: [makeThread({ branch: "feat/search" })], - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), - sortOrder: "updated_at", - icon: null, - getContentMatch: () => ({ - source: "assistant", - snippet: "The relay reconnect is now bounded.", - query: "reconnect", - }), - runThread: async (_thread) => undefined, - }); - - expect(item?.searchTerms).toContain("The relay reconnect is now bounded."); - expect(item?.threadContentMatch).toEqual({ - source: "assistant", - snippet: "The relay reconnect is now bounded.", - query: "reconnect", - }); - expect(item?.description).toBe("T3 Code · #feat/search"); - }); - it("surfaces threads when the query is their ID, without outranking title matches", () => { const idThread = makeThread({ id: ThreadId.make("thread-alpha-1234"), @@ -712,102 +353,9 @@ describe("buildThreadActionItems", () => { expect.objectContaining({ value: `thread:${idThread.id}` }), ]); }); - - it("prefers renderDescription when provided", () => { - const [item] = buildThreadActionItems({ - threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], - projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), - sortOrder: "updated_at", - icon: null, - renderDescription: (thread, { projectTitle }) => - `${projectTitle}:${thread.branch}:${thread.worktreePath ? "wt" : "local"}`, - runThread: async (_thread) => undefined, - }); - - expect(item?.description).toBe("T3 Code:feat/search:wt"); - }); - - it("filters archived threads out of thread search items", () => { - const items = buildThreadActionItems({ - threads: [ - makeThread({ - id: ThreadId.make("thread-active"), - title: "Active thread", - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-19T00:00:00.000Z", - }), - makeThread({ - id: ThreadId.make("thread-archived"), - title: "Archived thread", - archivedAt: "2026-03-20T00:00:00.000Z", - updatedAt: "2026-03-20T00:00:00.000Z", - }), - ], - projectTitleById: new Map([[PROJECT_ID, "Project"]]), - sortOrder: "updated_at", - icon: null, - runThread: async (_thread) => undefined, - }); - - expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); - }); -}); - -describe("buildBrowseGroups", () => { - it("waits for asynchronous browse navigation actions", async () => { - let finishNavigation: (() => void) | undefined; - const browseTo = vi.fn( - () => - new Promise((resolve) => { - finishNavigation = resolve; - }), - ); - const groups = buildBrowseGroups({ - browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }], - browseQuery: "~/", - canBrowseUp: false, - upIcon: null, - directoryIcon: null, - browseUp: vi.fn(), - browseTo, - }); - const item = groups[0]?.items[0]; - if (!item || item.kind !== "action") { - throw new Error("Expected a browse action"); - } - - let actionSettled = false; - const action = item.run().then(() => { - actionSettled = true; - }); - await Promise.resolve(); - - expect(browseTo).toHaveBeenCalledWith("Downloads"); - expect(actionSettled).toBe(false); - - finishNavigation?.(); - await action; - expect(actionSettled).toBe(true); - }); }); describe("filterPinnedBrowseEntries", () => { - const entries = [ - { name: "repo", fullPath: "/projects/repo" }, - { name: "work", fullPath: "/projects/work" }, - ]; - - it("shows sibling folders without losing an existing pinned destination", () => { - expect( - filterPinnedBrowseEntries({ - browseEntries: entries, - filterQuery: "repo", - pinnedDirectoryName: "repo", - caseSensitive: true, - }), - ).toEqual({ visibleEntries: entries, exactEntry: entries[0] }); - }); - it("matches an existing pinned destination without Windows casing", () => { const windowsEntries = [ { name: "Repo", fullPath: "C:\\projects\\Repo" }, @@ -869,79 +417,6 @@ it.each([ ]); }); -describe("filterCommandPaletteGroups", () => { - it("sorts secondary settings results after other matches", () => { - const item = (value: string, title: string, secondary?: boolean) => - ({ - kind: "action", - value, - title, - searchTerms: [title, "General"], - icon: null, - run: async () => undefined, - ...(secondary ? { secondary } : {}), - }) satisfies CommandPaletteActionItem; - const [group] = filterCommandPaletteGroups({ - activeGroups: [], - query: "model", - isInSubmenu: false, - projectSearchItems: [], - settingsSearchItems: [ - item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true), - item("setting:default-model", "Default model"), - ], - threadSearchItems: [], - }); - expect(group?.items.map((entry) => entry.value)).toEqual([ - "setting:default-model", - "setting:keybinding-modelPicker.toggle", - ]); - }); -}); - -describe("virtualized command palette rows", () => { - const action = (value: string, disabled = false): CommandPaletteActionItem => ({ - kind: "action", - value, - searchTerms: [], - title: value, - icon: null, - ...(disabled ? { disabled } : {}), - run: async () => {}, - }); - const groups: CommandPaletteGroup[] = [ - { value: "actions", label: "Actions", items: [action("new-thread"), action("offline", true)] }, - { value: "threads", label: "Threads", items: [action("thread-a"), action("thread-b")] }, - ]; - - it("keeps group order and headings while indexing only enabled items", () => { - const { rows, itemValues, rowIndexByItemIndex } = buildCommandPaletteRows(groups); - - expect(rows.map((row) => (row.kind === "label" ? `# ${row.label}` : row.key))).toEqual([ - "# Actions", - "actions:new-thread", - "actions:offline", - "# Threads", - "threads:thread-a", - "threads:thread-b", - ]); - expect(itemValues).toEqual(["new-thread", "thread-a", "thread-b"]); - expect(rowIndexByItemIndex).toEqual([1, 4, 5]); - expect(rows.flatMap((row) => (row.kind === "item" ? [row.itemIndex] : []))).toEqual([ - 0, - null, - 1, - 2, - ]); - }); - - it("resolves Enter to the highlighted item without needing its row mounted", () => { - expect(findHighlightedCommandPaletteItem(groups, "thread-b")?.value).toBe("thread-b"); - expect(findHighlightedCommandPaletteItem(groups, "offline")).toBeNull(); - expect(findHighlightedCommandPaletteItem(groups, null)).toBeNull(); - }); -}); - describe("command palette search return", () => { const picker = { addonIcon: null, groups: [] }; @@ -1017,28 +492,4 @@ describe("returning from file and content search", () => { expect(reduceCommandPaletteUiState(closed, { _tag: "BackToCommand" })).toEqual(closed); expect(reduceCommandPaletteUiState(closed, { _tag: "SetOpen", open: true })).toEqual(root); }); - - it("a fresh overlay shortcut has no parent search to return to", () => { - const child = reduceCommandPaletteUiState(root, { - _tag: "OpenChildSearch", - mode: "files", - context, - }); - const fresh = reduceCommandPaletteUiState(child, { _tag: "ToggleMode", mode: "content" }); - expect(fresh).toEqual({ open: true, mode: "content", openIntent: null }); - expect(reduceCommandPaletteUiState(fresh, { _tag: "BackToCommand" })).toEqual(root); - }); - - it("a direct search intent replaces restored query and linked-thread context", () => { - const child = reduceCommandPaletteUiState(root, { - _tag: "OpenChildSearch", - mode: "content", - context, - }); - const returned = reduceCommandPaletteUiState(child, { _tag: "BackToCommand" }); - expect(reduceCommandPaletteUiState(returned, { _tag: "OpenSearch", query: "fresh" })).toEqual({ - ...root, - openIntent: { kind: "search", query: "fresh" }, - }); - }); }); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 0f61421d8f5d..f6d29043abf3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,9 +1,19 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; +import { + addProjectRemoteSourceLabel, + addProjectRemoteSourcePathHint, + buildAddProjectRemoteSourceReadiness, + type AddProjectRemoteSource, + type AddProjectRemoteSourceReadiness, +} from "@t3tools/client-runtime/operations/projects"; import { type EnvironmentId, + type EnvironmentMachineKind, type FilesystemBrowseEntry, type KeybindingCommand, + type SourceControlDiscoveryResult, + type SourceControlRepositoryInfo, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; @@ -348,7 +358,6 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; - disabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -363,7 +372,6 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), - ...(input.disabled ? { disabled: input.disabled(project) } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); @@ -692,3 +700,87 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str return "Enter path (e.g. ~/projects/my-app)"; } } + +export function getEnvironmentBrowsePlatform(os: string | null | undefined): string { + if (os === "windows") { + return "Win32"; + } + if (os === "darwin") { + return "MacIntel"; + } + if (os === "linux") { + return "Linux"; + } + return typeof navigator === "undefined" ? "" : navigator.platform; +} + +export interface AddProjectEnvironmentOption { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly machine: EnvironmentMachineKind; + readonly isPrimary: boolean; + readonly isConnected: boolean; + readonly status: string; +} + +export type AddProjectCloneFlow = + | { + readonly step: "repository"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + } + | { + readonly step: "confirm"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo | null; + readonly remoteUrl: string; + }; + +export function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { + if (!flow) return null; + if (flow.step === "confirm") return null; + if (flow.source === "url") { + return "Enter Git clone URL"; + } + return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`; +} + +export function buildPaletteRemoteSourceReadiness( + discovery: SourceControlDiscoveryResult | null, +): AddProjectRemoteSourceReadiness { + const readiness = buildAddProjectRemoteSourceReadiness(discovery); + return (source) => { + const entry = readiness(source); + return entry.hint === null + ? entry + : { + ...entry, + hint: entry.hint.replace( + "Open Source Control settings", + "Open Settings -> Source Control", + ), + }; + }; +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "An error occurred."; +} + +const OVERLAY_MODE_BY_COMMAND = { + "commandPalette.toggle": "command", + "filePicker.toggle": "files", + "projectSearch.toggle": "content", +} as const satisfies Partial>; + +export function overlayModeForCommand(command: string | null): SearchOverlayMode | null { + if (command === null) return null; + return command in OVERLAY_MODE_BY_COMMAND + ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] + : null; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1a0ff6800f7b..1e1f0fda3b04 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,3 @@ -"use client"; - import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -14,19 +12,16 @@ import { getNewProjectPublishTarget, getNewProjectPublishTargets, getNewProjectRepository, - type NewProjectPublishTarget, normalizePastedCloneUrl, addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, - buildAddProjectRemoteSourceReadiness, sortAddProjectProviderSources, type AddProjectRemoteSource, type AddProjectRemoteSourceReadiness, } from "@t3tools/client-runtime/operations/projects"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { canPreloadBrowsePath, @@ -44,67 +39,33 @@ import { AuthSourceControlWriteScope, AuthFilesystemReadScope, type DesktopWslState, - type ClientSettings, type EnvironmentId, - type EnvironmentMachineKind, type FilesystemBrowseResult, - type ProjectId, - type SourceControlDiscoveryResult, SourceControlProviderKind, - type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; -import { openGitHubIssuePicker } from "./chat/GitHubIssuePicker"; -import { useThreadGitHubIssueLink, useUnlinkGitHubIssue } from "./chat/GitHubIssueThreadLink"; -import { openNotionPagePicker } from "./chat/NotionPagePicker"; -import { openGranolaNotePicker } from "./chat/GranolaNotePicker"; -import { openSlackMessagePicker } from "./chat/SlackMessagePicker"; -import { openDiscordMessagePicker } from "./chat/DiscordMessagePicker"; -import { openLinearIssuePicker } from "./chat/LinearIssuePicker"; -import { useThreadLinearLink, useUnlinkLinearIssue } from "./chat/LinearThreadLink"; -import { useThreadNotionLink, useUnlinkNotionPage } from "./chat/NotionThreadLink"; -import { useThreadGranolaLink, useUnlinkGranolaNote } from "./chat/GranolaThreadLink"; -import { useThreadSlackLink, useUnlinkSlackThread } from "./chat/SlackThreadLink"; -import { useThreadDiscordLink, useUnlinkDiscordThread } from "./chat/DiscordThreadLink"; -import { openThreadAttachPicker } from "./chat/ThreadAttachPicker"; -import { openPullRequestAttachPicker } from "./chat/PullRequestAttachPicker"; -import { openRepositoryAttachPicker } from "./chat/RepositoryAttachPicker"; -import { openStartFromPicker } from "./chat/StartFromPicker"; -import { dispatchSplitViewAction, useSplitPartner } from "./chat/splitPane"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, - Columns2Icon, CheckIcon, ChevronRightIcon, CornerLeftUpIcon, - BotIcon, - CalendarClockIcon, - DownloadIcon, FileSearchIcon, FolderGit2Icon, FolderIcon, FolderPlusIcon, - GitBranchIcon, - HistoryIcon, - InboxIcon, - LayersIcon, MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, - MonitorIcon, - MoonIcon, PaletteIcon, RotateCcwIcon, SettingsIcon, + FileTextIcon, SquarePenIcon, - SunIcon, TextSearchIcon, - FileTextIcon, } from "lucide-react"; -import { DiscordIcon, GitHubIcon, GranolaIcon, LinearIcon, NotionIcon, SlackIcon } from "./Icons"; import { requestThreadFindOpen } from "./chat/threadFindActionBus"; import { useDeferredValue, @@ -122,7 +83,6 @@ import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; -import { useAttentionInbox } from "../hooks/useAttentionInbox"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; @@ -131,33 +91,19 @@ import { copyPromptLink, promptLinkRepositoryOf, } from "./promptLinks/copyPromptLink"; -import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; +import { useClientSettings } from "../hooks/useSettings"; import { useStableCallback } from "../hooks/useStableCallback"; import { useTheme } from "../hooks/useTheme"; -import { useCustomThemes } from "../hooks/useCustomThemes"; -import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; -import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { getThemeDefinition } from "../themePalette"; -import { - STANDARD_THEME_CARDS, - getThemeCardDefinition, - ThemePreviewCircle, -} from "./settings/ThemePreviewCircles"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesystem"; -import { githubIssueEnvironment } from "../state/githubIssues"; -import { linearEnvironment } from "../state/linear"; -import { notionEnvironment } from "../state/notion"; -import { granolaEnvironment } from "../state/granola"; import { projectEnvironment } from "../state/projects"; -import { slackEnvironment } from "../state/slack"; -import { discordEnvironment } from "../state/discord"; import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { threadEnvironment } from "../state/threads"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; -import { useRestartAgentSession } from "../hooks/useRestartAgentSession"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; import { useNewProject } from "../hooks/useNewProject"; @@ -167,15 +113,7 @@ import { useEnvironments, usePrimaryEnvironmentId, } from "../state/environments"; -import { - readThreadVisibleTurnItems, - useProjects, - useServerConfigs, - useThreadShells, - waitForProject, -} from "../state/entities"; -import { isGitHubProject } from "@t3tools/client-runtime/state/github-issues"; -import { useThreadSearch } from "../state/queries"; +import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -205,8 +143,7 @@ import { newProjectId, } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; -import { formatRelativeTimeLabel } from "../timestampFormat"; +import { buildThreadRouteParams } from "../threadRoutes"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, @@ -218,10 +155,7 @@ import { ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, - buildCommandPaletteProjectMetadata, - buildProjectActionItems, buildRootGroups, - buildThreadActionItems, buildLinkedThreadActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, @@ -240,288 +174,68 @@ import { RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, createCommandPaletteSearchState, + type AddProjectCloneFlow, + type AddProjectEnvironmentOption, + buildPaletteRemoteSourceReadiness, + errorMessage, + getEnvironmentBrowsePlatform, + overlayModeForCommand, + remoteProjectInputPlaceholder, reduceCommandPaletteSearchState, type SearchOverlayMode, } from "./CommandPalette.logic"; -import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { - CommandPaletteTitleIcon, + selectCommandPaletteClientSettings, + useEnvironmentLookups, + useProjectLookups, + useSortedProjectGroups, +} from "./CommandPaletteLookups"; +import { + buildNewThreadInProjectItems, + buildProjectSearchItems, +} from "./CommandPaletteProjectItems"; +import { + CommandPaletteSubmitButton, + NewProjectPublishHostPicker, + NewProjectSummary, + RemoteRepositorySummary, + remoteProjectSourceIcon, + sourceControlHostIcon, +} from "./CommandPaletteAddProject"; +import { useCommandPaletteThreadItems } from "./CommandPaletteThreadItems"; +import { + APPEARANCE_OPTIONS, + notifyThemeSaveFailure, + useCommandPaletteThemeItems, +} from "./CommandPaletteThemeItems"; +import { CommandPaletteVirtualizedResults, scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; -import { sourceControlIcon } from "~/sourceControlPresentation"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Checkbox } from "./ui/checkbox"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { - ATTENTION_REASON_PRESENTATION, - useProjectTitleByKey, -} from "./sidebar/SidebarAttentionInbox"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; -import { openTranscriptExportDialog } from "./TranscriptExportDialog"; -import { openImportConversationDialog } from "./ImportConversationDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; -import { - COMMAND_PALETTE_META_ICON_CLASS, - CommandPaletteMetaDot, - ThreadCommandSubtitle, -} from "./ThreadCommandSubtitle"; -import { - ThreadRowLeadingStatus, - ThreadRowTrailingStatus, - ThreadSearchPullRequestNumber, -} from "./ThreadStatusIndicators"; -import { - primaryServerKeybindingsAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../state/server"; -import { useRunScheduledTask } from "../hooks/useRunThreadScheduledTask"; -import { resolveThreadScheduledTasks } from "../threadScheduledTasks"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - type ProviderInstanceEntry, -} from "../providerInstances"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; -import { Kbd, KbdGroup } from "./ui/kbd"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; -import { getProjectOrderKey } from "../logicalProject"; -import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { - buildSidebarProjectPickerEntries, - buildSidebarProjectSnapshots, -} from "../sidebarProjectGrouping"; -import type { Project } from "../types"; +import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -function getEnvironmentBrowsePlatform(os: string | null | undefined): string { - if (os === "windows") { - return "Win32"; - } - if (os === "darwin") { - return "MacIntel"; - } - if (os === "linux") { - return "Linux"; - } - return typeof navigator === "undefined" ? "" : navigator.platform; -} - -interface AddProjectEnvironmentOption { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly machine: EnvironmentMachineKind; - readonly isPrimary: boolean; - readonly isConnected: boolean; - readonly status: string; -} - -type AddProjectCloneFlow = - | { - readonly step: "repository"; - readonly environmentId: EnvironmentId; - readonly source: AddProjectRemoteSource; - } - | { - readonly step: "confirm"; - readonly environmentId: EnvironmentId; - readonly source: AddProjectRemoteSource; - readonly repositoryInput: string; - readonly repository: SourceControlRepositoryInfo | null; - readonly remoteUrl: string; - }; - -function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { - if (source === "url") return ; - const Icon = sourceControlIcon(sourceControlClients.get(source)); - return ; -} - -function sourceControlHostIcon( - definition: NewProjectPublishTarget["definition"], - className: string, -): ReactNode { - const Icon = sourceControlIcon(definition); - return ; -} - -function projectFaviconIcon(project: Project): ReactNode { - return ; -} - -function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { - if (!flow) return null; - if (flow.step === "confirm") return null; - if (flow.source === "url") { - return "Enter Git clone URL"; - } - return `Enter ${addProjectRemoteSourceLabel(flow.source)} repository (${addProjectRemoteSourcePathHint(flow.source)})`; -} - -function buildPaletteRemoteSourceReadiness( - discovery: SourceControlDiscoveryResult | null, -): AddProjectRemoteSourceReadiness { - const readiness = buildAddProjectRemoteSourceReadiness(discovery); - return (source) => { - const entry = readiness(source); - return entry.hint === null - ? entry - : { - ...entry, - hint: entry.hint.replace( - "Open Source Control settings", - "Open Settings -> Source Control", - ), - }; - }; -} - -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - return "An error occurred."; -} - -const OVERLAY_MODE_BY_COMMAND = { - "commandPalette.toggle": "command", - "filePicker.toggle": "files", - "projectSearch.toggle": "content", -} as const satisfies Partial>; - -function overlayModeForCommand(command: string | null): SearchOverlayMode | null { - if (command === null) return null; - return command in OVERLAY_MODE_BY_COMMAND - ? OVERLAY_MODE_BY_COMMAND[command as keyof typeof OVERLAY_MODE_BY_COMMAND] - : null; -} - -const APPEARANCE_OPTIONS = [ - { mode: "system", label: "System", icon: MonitorIcon }, - { mode: "light", label: "Light", icon: SunIcon }, - { mode: "dark", label: "Dark", icon: MoonIcon }, -] as const; - -function notifyThemeSaveFailure(): void { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Couldn't save theme selection", - description: "Try again.", - }), - ); -} - -// Each helper reads its own source hook so the compiler caches the derived value on that -// source. Taking the source as an argument instead leaves the helper hook-free, and the -// compiler then treats it as a plain function and caches nothing. -function useThemeCards() { - const customThemes = useCustomThemes(); - const environmentThemes = useEnvironmentThemeDefinitions(); - const seen = new Set(); - return [ - ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), - ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] - .filter((definition) => { - if (seen.has(definition.id)) return false; - seen.add(definition.id); - return true; - }) - .map(getThemeCardDefinition), - ]; -} - -function useEnvironmentLookups() { - const { environments } = useEnvironments(); - const environmentLabelById = new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ); - const projectEnvironmentLocationById = new Map( - environments.map((environment) => { - const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target); - return [ - environment.environmentId, - { - kind: isLocal ? "local" : "remote", - label: isPrimary ? "Local" : isLocal ? `${environment.label} (Local)` : environment.label, - machine: resolveEnvironmentMachineKind(environment.serverConfig), - }, - ] as const; - }), - ); - return { environmentLabelById, projectEnvironmentLocationById }; -} - -function useProjectLookups() { - const projects = useProjects(); - const projectCwdById = new Map( - projects.map((project) => [project.id, project.workspaceRoot]), - ); - const projectByKey = new Map( - projects.map((project) => [`${project.environmentId}:${project.id}`, project]), - ); - const projectTitleById = new Map( - projects.map((project) => [project.id, project.title]), - ); - return { projectCwdById, projectByKey, projectTitleById }; -} - -const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ - sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, - sidebarProjectSortOrder: settings.sidebarProjectSortOrder, - sidebarThreadSortOrder: settings.sidebarThreadSortOrder, -}); - -function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { - const projects = useProjects(); - const threads = useThreadShells(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const clientSettings = useClientSettings(selectCommandPaletteClientSettings); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const projectGroupingSettings = { - sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, - sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, - }; - const orderedProjects = orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - const unsortedProjectGroups = buildSidebarProjectSnapshots({ - projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }); - return sortLogicalProjectsForSidebar( - unsortedProjectGroups, - threads, - clientSettings.sidebarProjectSortOrder, - ); -} - export function CommandPalette({ children }: { children: ReactNode }) { const navigate = useNavigate(); const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { @@ -534,9 +248,6 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openAddProject = () => dispatch({ _tag: "OpenAddProject" }); const openNewThreadIn = () => dispatch({ _tag: "OpenNewThreadIn" }); const clearOpenIntent = () => dispatch({ _tag: "ClearOpenIntent" }); - const openFileSearch = (query: string) => dispatch({ _tag: "OpenFileSearch", query }); - const openCommandSearchFromFiles = (query: string) => - dispatch({ _tag: "OpenSearch", query, fromFilePicker: true }); const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => dispatch({ _tag: "OpenChildSearch", mode, context }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -613,13 +324,6 @@ export function CommandPalette({ children }: { children: ReactNode }) { }); return; } - if (command === "attentionInbox.open") { - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - dispatch({ _tag: "OpenAttentionInbox" }); - return; - } if (command === "usage.open") { event.preventDefault(); event.stopPropagation(); @@ -658,8 +362,6 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); - } else if (detail.open === "run-scheduled-task") { - dispatch({ _tag: "OpenRunScheduledTask" }); } else if (detail.query !== undefined) { dispatch({ _tag: "OpenSearch", @@ -695,8 +397,10 @@ export function CommandPalette({ children }: { children: ReactNode }) { setOpen={setOpen} openChildSearch={openChildSearch} initialContext={state.restoredSearch} - openFileSearch={openFileSearch} - openCommandSearchFromFiles={openCommandSearchFromFiles} + initialFileQuery={state.openIntent?.kind === "file-search" ? state.openIntent.query : ""} + openCommandSearch={(query) => + dispatch({ _tag: "OpenSearch", query, fromFilePicker: true }) + } clearOpenIntent={clearOpenIntent} /> @@ -713,8 +417,8 @@ function CommandPaletteDialog(props: { context: CommandPaletteReturnContext, ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; - readonly openCommandSearchFromFiles: (query: string) => void; - readonly openFileSearch: (query: string) => void; + readonly initialFileQuery: string; + readonly openCommandSearch: (query: string) => void; readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); @@ -744,9 +448,9 @@ function CommandPaletteDialog(props: { > {props.mode === "files" ? ( ) : props.mode === "content" ? ( @@ -756,7 +460,6 @@ function CommandPaletteDialog(props: { setOpen={props.setOpen} openChildSearch={props.openChildSearch} initialContext={props.initialContext} - openFileSearch={props.openFileSearch} clearOpenIntent={props.clearOpenIntent} /> )} @@ -779,20 +482,12 @@ function OpenCommandPaletteDialog(props: { context: CommandPaletteReturnContext, ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; - readonly openFileSearch: (query: string) => void; readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); - const attentionEntries = useAttentionInbox(); - const attentionProjectTitleByKey = useProjectTitleByKey(); - const splitRouteTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const splitRouteRef = splitRouteTarget?.kind === "server" ? splitRouteTarget.threadRef : null; - const splitViewOpen = useSplitPartner(splitRouteRef) !== null; - const { clearOpenIntent, openIntent, openChildSearch, openFileSearch, setOpen } = props; + const composerHandleRef = useComposerHandleContext(); + const { clearOpenIntent, openIntent, openChildSearch, setOpen } = props; const initialSearch = props.initialContext ?? { query: openIntent?.kind === "search" ? openIntent.query : "", linkedThreadSearch: openIntent?.kind === "search" ? openIntent : null, @@ -805,9 +500,6 @@ function OpenCommandPaletteDialog(props: { const { query, views: viewStack } = searchState; const setQuery = (query: string) => dispatchSearch({ type: "query", query }); const [linkedThreadSearch, setLinkedThreadSearch] = useState(initialSearch.linkedThreadSearch); - const [fromFilePicker, setFromFilePicker] = useState( - initialSearch.linkedThreadSearch?.fromFilePicker === true, - ); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -817,7 +509,7 @@ function OpenCommandPaletteDialog(props: { highlightClearedRef.current = true; setHighlightedItemValue(null); } - const clientSettings = useClientSettings(); + const clientSettings = useClientSettings(selectCommandPaletteClientSettings); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -835,7 +527,12 @@ function OpenCommandPaletteDialog(props: { const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); - const restartAgentSession = useRestartAgentSession(); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -843,7 +540,6 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const paletteRouteTarget = useThreadRouteTarget(); - const composerHandleRef = useComposerHandleContext(); const projects = useProjects(); const referenceThreadRef = pathname === "/pull-requests" @@ -856,61 +552,7 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); - const activeLinkThreadRef = activeThread - ? scopeThreadRef(activeThread.environmentId, activeThread.id) - : activeDraftThread - ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) - : null; - const scheduledTaskThreadRef = activeThread - ? scopeThreadRef(activeThread.environmentId, activeThread.id) - : null; - const canRunScheduledTasks = useAtomValue( - serverEnvironment.runScheduledTaskNow.permissionAtom( - scheduledTaskThreadRef?.environmentId ?? null, - ), - ); - // Subscribed only while the palette is open; the dialog unmounts on close. - const scheduledTasksQuery = useEnvironmentQuery( - scheduledTaskThreadRef !== null && canRunScheduledTasks - ? serverEnvironment.scheduledTasksLive({ - environmentId: scheduledTaskThreadRef.environmentId, - input: {}, - }) - : null, - ); - const runScheduledTask = useRunScheduledTask(); - const activeLinearLink = useThreadLinearLink(activeLinkThreadRef); - const unlinkLinearIssue = useUnlinkLinearIssue(); - const canLinkLinearIssue = useAtomValue( - linearEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const activeSlackLink = useThreadSlackLink(activeLinkThreadRef); - const unlinkSlackThread = useUnlinkSlackThread(); - const activeNotionLink = useThreadNotionLink(activeLinkThreadRef); - const unlinkNotionPage = useUnlinkNotionPage(); - const canLinkSlack = useAtomValue( - slackEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const activeDiscordLink = useThreadDiscordLink(activeLinkThreadRef); - const unlinkDiscordThread = useUnlinkDiscordThread(); - const canLinkDiscord = useAtomValue( - discordEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const canLinkNotion = useAtomValue( - notionEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const activeGranolaLink = useThreadGranolaLink(activeLinkThreadRef); - const unlinkGranolaNote = useUnlinkGranolaNote(); - const canLinkGranola = useAtomValue( - granolaEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const activeGitHubIssueLink = useThreadGitHubIssueLink(activeLinkThreadRef); - const unlinkGitHubIssue = useUnlinkGitHubIssue(); - const canLinkGitHubIssue = useAtomValue( - githubIssueEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), - ); - const serverConfigs = useServerConfigs(); - const activeThreadServerConfig = serverConfigs.get( + const activeThreadServerConfig = useServerConfigs().get( activeThread?.environmentId ?? ("" as EnvironmentId), ); const activeThreadReferenceCopyTarget = @@ -947,47 +589,10 @@ function OpenCommandPaletteDialog(props: { }; const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { - theme, - themeHalves, - resolvedTheme, - appearanceMode, - setAppearanceMode, - setTheme, - setThemeHalf, - } = useTheme(); - const themeCards = useThemeCards(); - const providers = useAtomValue(primaryServerProvidersAtom); const providersByEnvironment = useEnvironmentProviders(); - const providerEntryByEnvironmentAndInstanceId = new Map( - environments.flatMap((environment) => { - const serverConfig = environment.serverConfig; - const derived = deriveProviderInstanceEntries( - providersByEnvironment.get(environment.environmentId) ?? - (environment.environmentId === primaryEnvironmentId ? providers : []), - ); - const entries = serverConfig - ? applyProviderInstanceSettings(derived, serverConfig.settings, null) - : derived; - return entries.map( - (entry) => [`${environment.environmentId}:${entry.instanceId}`, entry] as const, - ); - }), - ); + const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); const currentView = viewStack.at(-1)?.view ?? null; - const environmentIds = environments - .filter((environment) => environment.connection.phase === "connected") - .map((environment) => environment.environmentId); const threadSearchQuery = currentView === null && !isActionsOnly ? deferredQuery : ""; - const threadSearch = useThreadSearch(environmentIds, threadSearchQuery); - const threadContentMatchByKey = (() => - new Map( - threadSearch.matches.flatMap((match) => - match.source === "user" || match.source === "assistant" || match.source === "group_name" - ? [[threadSearchMatchKey(match), match] as const] - : [], - ), - ))(); const [browseGeneration, setBrowseGeneration] = useState(0); const [browseNavigation] = useState(createBrowseNavigationCoordinator); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( @@ -1002,7 +607,6 @@ function OpenCommandPaletteDialog(props: { const [newProjectPublishes, setNewProjectPublishes] = useState(false); const [newProjectPublishKind, setNewProjectPublishKind] = useState(null); - const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false); const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); // State lags a render behind, so a repeated Enter could start a second create. const newProjectSubmittingRef = useRef(false); @@ -1022,20 +626,6 @@ function OpenCommandPaletteDialog(props: { groups: projectGroups, preferredProjectRef: contextualProjectRef, }); - const isEnvironmentReachable = (environmentId: EnvironmentId) => - environments.some( - (environment) => - environment.environmentId === environmentId && environment.connection.phase === "connected", - ); - const threadPickerProjects = buildSidebarProjectPickerEntries({ - groups: projectGroups, - preferredProjectRef: contextualProjectRef, - expandCheckouts: true, - isEnvironmentReachable, - }).map(({ group, targetProject }) => ({ - ...targetProject, - displayName: group.displayName, - })); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, displayName: group.displayName, @@ -1271,39 +861,10 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }; - const projectSearchItems = buildProjectActionItems({ + const projectSearchItems = buildProjectSearchItems({ projects: pickerProjects, - valuePrefix: "project", - searchTerms: (project) => { - const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) - ?.memberProjects ?? [project]; - return buildCommandPaletteProjectMetadata({ - projects: members, - locationByEnvironmentId: projectEnvironmentLocationById, - }).searchTerms; - }, - renderDescription: (project) => { - const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) - ?.memberProjects ?? [project]; - const metadata = buildCommandPaletteProjectMetadata({ - projects: members, - locationByEnvironmentId: projectEnvironmentLocationById, - }); - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote" as const, - label: "Remote", - machine: "server" as const, - }; - return ( - 1} - location={location} - workspaceRoot={project.workspaceRoot} - /> - ); - }, - icon: projectFaviconIcon, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, runProject: openProjectFromSearch, }); @@ -1311,59 +872,30 @@ function OpenCommandPaletteDialog(props: { const isScratch = (project: CommandPaletteProject) => isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); const projectItems = enumerateCommandPaletteItems( - buildProjectActionItems({ - projects: threadPickerProjects.filter((project) => !isScratch(project)), - valuePrefix: "new-thread-in", - disabled: (project) => !isEnvironmentReachable(project.environmentId), - searchTerms: (project) => [ - projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", - ], - renderDescription: (project) => { - const environment = environments.find( - (candidate) => candidate.environmentId === project.environmentId, - ); - const location = projectEnvironmentLocationById.get(project.environmentId) ?? { - kind: "remote", - label: "Remote", - machine: "server" as const, - }; - return ( - - - {location.kind === "remote" ? ( - - ) : null} - {location.label} - - - {project.workspaceRoot} - {isEnvironmentReachable(project.environmentId) ? null : ( - <> - - - {environment ? connectionStatusTitle(environment.connection) : "Unavailable"} - - - )} - - ); - }, - icon: projectFaviconIcon, + buildNewThreadInProjectItems({ + projects: pickerProjects.filter((project) => !isScratch(project)), + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id), { - environmentSelection: "manual", - }); + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); + await handleNewThread( + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), + ); }, }), ); if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = - threadPickerProjects[0] !== undefined && isScratch(threadPickerProjects[0]) ? 0 : 1; + const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; return [ ...projectItems.slice(0, noProjectIndex), { @@ -1379,73 +911,8 @@ function OpenCommandPaletteDialog(props: { ]; })(); - const allThreadItems = (() => - buildThreadActionItems({ - threads, - ...(activeThreadId ? { activeThreadId } : {}), - projectTitleById, - sortOrder: clientSettings.sidebarThreadSortOrder, - icon: , - renderLeadingContent: (thread) => , - renderTrailingContent: (thread) => ( - <> - - - - ), - renderDescription: (thread, { projectTitle }) => { - const modelInstanceId = - thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = - providerEntryByEnvironmentAndInstanceId.get( - `${thread.environmentId}:${modelInstanceId}`, - ) ?? null; - return ( - - ); - }, - getContentMatch: (thread) => { - const match = threadContentMatchByKey.get( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ); - return match && - (match.source === "user" || match.source === "assistant" || match.source === "group_name") - ? { - source: match.source, - snippet: match.snippet, - query: threadSearch.query, - } - : undefined; - }, - runThread: async (thread) => { - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, - }))(); + const { threadItems: allThreadItems, isSearchPending: isThreadSearchPending } = + useCommandPaletteThreadItems({ activeThreadId, threadSearchQuery }); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); // Stable so the item builders that capture it during render never see the @@ -1471,15 +938,6 @@ function OpenCommandPaletteDialog(props: { } function handleQueryChange(nextQuery: string): void { - if ( - fromFilePicker && - currentView === null && - query.startsWith(">") && - !nextQuery.startsWith(">") - ) { - openFileSearch(nextQuery); - return; - } browseNavigation.invalidate(); clearTypedHighlight(); setQuery(nextQuery); @@ -1769,63 +1227,6 @@ function OpenCommandPaletteDialog(props: { }); }, }); - actionItems.push({ - kind: "action", - value: "action:start-thread-from", - searchTerms: [ - "pull request", - "pr", - "branch", - "issue", - "linear", - "checkout", - "start from", - "automation", - "scheduled task", - ], - title: ( - <> - New thread from PR, branch, or issue in{" "} - {activeProjectTitle} - - ), - icon: , - shortcutCommand: "chat.startFrom", - run: async () => { - const started = await startNewThreadFromContext({ - activeDraftThread, - activeThread: activeThread ?? undefined, - defaultProjectRef, - handleNewThread, - }); - if (started) openStartFromPicker(); - }, - }); - } - if ( - [...serverConfigs.values()].some( - (config) => config.environment.capabilities.agentSessionPicker === true, - ) - ) { - actionItems.push({ - kind: "action", - value: "action:import-conversation", - searchTerms: [ - "import", - "conversation", - "claude code", - "codex", - "conductor", - "session", - "resume", - "cli", - ], - title: "Import conversation…", - icon: , - run: async () => { - openImportConversationDialog(); - }, - }); } actionItems.push({ @@ -1835,7 +1236,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); } @@ -1941,72 +1342,6 @@ function OpenCommandPaletteDialog(props: { }); } - const runScheduledTaskItems: CommandPaletteActionItem[] = - scheduledTaskThreadRef === null || !scheduledTasksQuery.data - ? [] - : resolveThreadScheduledTasks( - scheduledTasksQuery.data.tasks, - scheduledTaskThreadRef.threadId, - readThreadVisibleTurnItems(scheduledTaskThreadRef), - ).map((task) => ({ - kind: "action", - value: `scheduled-task:${task.id}`, - title: task.title, - searchTerms: [task.title, "scheduled task", "automation", "run now"], - icon: , - run: async () => { - await runScheduledTask(scheduledTaskThreadRef.environmentId, task); - }, - })); - const singleScheduledTask = runScheduledTaskItems.length === 1 ? runScheduledTaskItems[0] : null; - if (singleScheduledTask) { - actionItems.push({ - ...singleScheduledTask, - value: "action:run-scheduled-task", - title: "Run scheduled task now", - description: singleScheduledTask.title, - shortcutCommand: "thread.runScheduledTask", - }); - } else if (runScheduledTaskItems.length > 1) { - actionItems.push({ - kind: "submenu", - value: "action:run-scheduled-task", - searchTerms: ["scheduled task", "automation", "run now"], - title: "Run scheduled task now...", - icon: , - addonIcon: , - shortcutCommand: "thread.runScheduledTask", - groups: [ - { value: "run-scheduled-task", label: "Scheduled tasks", items: runScheduledTaskItems }, - ], - }); - } - - if (activeThread) { - const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); - actionItems.push({ - kind: "action", - value: "action:export-transcript", - searchTerms: ["export", "transcript", "markdown", "save", "copy", "conversation"], - title: "Export transcript", - icon: , - run: async () => { - openTranscriptExportDialog(threadRef); - }, - }); - // Fork: the thread's agent fleet in the right panel. - actionItems.push({ - kind: "action", - value: "action:open-agents-panel", - searchTerms: ["agents", "subagents", "fleet", "tasks", "panel"], - title: "Show agents", - icon: , - run: async () => { - useRightPanelStore.getState().open(threadRef, "agents"); - }, - }); - } - if ( activeThread !== null && threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" @@ -2037,305 +1372,6 @@ function OpenCommandPaletteDialog(props: { } } - const composerThreadRef = activeThread - ? scopeThreadRef(activeThread.environmentId, activeThread.id) - : activeDraftThread - ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) - : null; - if (composerThreadRef !== null) { - actionItems.push({ - kind: "action", - value: "action:attach-thread", - searchTerms: ["thread", "chat", "conversation", "summary", "attach", "context"], - title: "Attach thread", - icon: , - run: async () => { - openThreadAttachPicker(composerThreadRef); - }, - }); - actionItems.push({ - kind: "action", - value: "action:attach-linear-issue", - searchTerms: ["linear", "issue", "ticket", "attach", "context"], - title: "Attach Linear issue", - icon: , - run: async () => { - openLinearIssuePicker(composerThreadRef); - }, - }); - if ( - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableNotionIntegration !== false - ) { - actionItems.push({ - kind: "action", - value: "action:attach-notion-page", - searchTerms: ["notion", "page", "document", "attach", "context"], - title: "Attach Notion page", - icon: , - run: async () => { - openNotionPagePicker(composerThreadRef); - }, - }); - } - if ( - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableGranolaIntegration !== - false - ) { - actionItems.push({ - kind: "action", - value: "action:attach-granola-note", - searchTerms: ["granola", "meeting", "notes", "transcript", "call", "attach", "context"], - title: "Attach Granola note", - icon: , - run: async () => { - openGranolaNotePicker(composerThreadRef); - }, - }); - } - if ( - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableSlackIntegration !== false - ) { - actionItems.push({ - kind: "action", - value: "action:attach-slack-message", - searchTerms: ["slack", "message", "thread", "conversation", "attach", "context"], - title: "Attach Slack message", - icon: , - run: async () => { - openSlackMessagePicker(composerThreadRef); - }, - }); - } - if ( - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableDiscordIntegration !== - false - ) { - actionItems.push({ - kind: "action", - value: "action:attach-discord-message", - searchTerms: ["discord", "message", "thread", "conversation", "attach", "context"], - title: "Attach Discord message", - icon: , - run: async () => { - openDiscordMessagePicker(composerThreadRef); - }, - }); - } - // Issues are listed from the project's GitHub repository, so only offer them there. - const composerProject = projectByKey.get( - `${composerThreadRef.environmentId}:${currentProjectId ?? ""}`, - ); - if (isGitHubProject(composerProject)) { - actionItems.push({ - kind: "action", - value: "action:attach-github-issue", - searchTerms: ["github", "issue", "ticket", "attach", "context"], - title: "Attach GitHub issue", - icon: , - run: async () => { - openGitHubIssuePicker(composerThreadRef); - }, - }); - if (canLinkGitHubIssue) { - actionItems.push({ - kind: "action", - value: "action:link-github-issue", - searchTerms: ["github", "issue", "ticket", "link", "thread"], - title: activeGitHubIssueLink - ? `Change linked GitHub issue (#${activeGitHubIssueLink.number})` - : "Link GitHub issue", - icon: , - run: async () => { - openGitHubIssuePicker(composerThreadRef, "link"); - }, - }); - } - } - - // Unlinking needs no listing, so it stays offered even if the project stops being on GitHub. - if (canLinkGitHubIssue && activeGitHubIssueLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-github-issue", - searchTerms: ["github", "issue", "ticket", "unlink", "remove"], - title: `Unlink GitHub issue #${activeGitHubIssueLink.number}`, - icon: , - run: async () => { - await unlinkGitHubIssue(composerThreadRef); - }, - }); - } - if (canLinkLinearIssue) { - actionItems.push({ - kind: "action", - value: "action:link-linear-issue", - searchTerms: ["linear", "issue", "ticket", "link", "thread"], - title: activeLinearLink - ? `Change linked Linear issue (${activeLinearLink.identifier})` - : "Link Linear issue", - icon: , - run: async () => { - openLinearIssuePicker(composerThreadRef, "link"); - }, - }); - } - if (canLinkLinearIssue && activeLinearLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-linear-issue", - searchTerms: ["linear", "issue", "ticket", "unlink", "remove"], - title: `Unlink Linear issue ${activeLinearLink.identifier}`, - icon: , - run: async () => { - await unlinkLinearIssue(composerThreadRef); - }, - }); - } - if ( - canLinkSlack && - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableSlackIntegration === true - ) { - actionItems.push({ - kind: "action", - value: "action:link-slack-thread", - searchTerms: ["slack", "thread", "conversation", "link"], - title: activeSlackLink - ? `Change linked Slack thread (${activeSlackLink.channelLabel})` - : "Link Slack thread", - icon: , - run: async () => { - openSlackMessagePicker(composerThreadRef, "link"); - }, - }); - } - // Unlinking stays reachable with the integration off: it is the way out of a link. - if (canLinkSlack && activeSlackLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-slack-thread", - searchTerms: ["slack", "thread", "conversation", "unlink", "remove"], - title: `Unlink Slack thread in ${activeSlackLink.channelLabel}`, - icon: , - run: async () => { - await unlinkSlackThread(composerThreadRef); - }, - }); - } - if ( - canLinkDiscord && - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableDiscordIntegration === true - ) { - actionItems.push({ - kind: "action", - value: "action:link-discord-thread", - searchTerms: ["discord", "thread", "conversation", "link"], - title: activeDiscordLink - ? `Change linked Discord thread (${activeDiscordLink.channelLabel})` - : "Link Discord thread", - icon: , - run: async () => { - openDiscordMessagePicker(composerThreadRef, "link"); - }, - }); - } - // Unlinking stays reachable with the integration off: it is the way out of a link. - if (canLinkDiscord && activeDiscordLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-discord-thread", - searchTerms: ["discord", "thread", "conversation", "unlink", "remove"], - title: `Unlink Discord thread in ${activeDiscordLink.channelLabel}`, - icon: , - run: async () => { - await unlinkDiscordThread(composerThreadRef); - }, - }); - } - if ( - canLinkNotion && - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableNotionIntegration === true - ) { - actionItems.push({ - kind: "action", - value: "action:link-notion-page", - searchTerms: ["notion", "page", "document", "link"], - title: activeNotionLink - ? `Change linked Notion page (${activeNotionLink.title})` - : "Link Notion page", - icon: , - run: async () => { - openNotionPagePicker(composerThreadRef, "link"); - }, - }); - } - if (canLinkNotion && activeNotionLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-notion-page", - searchTerms: ["notion", "page", "document", "unlink", "remove"], - title: `Unlink Notion page ${activeNotionLink.title}`, - icon: , - run: async () => { - await unlinkNotionPage(composerThreadRef); - }, - }); - } - if ( - canLinkGranola && - serverConfigs.get(composerThreadRef.environmentId)?.settings.enableGranolaIntegration === true - ) { - actionItems.push({ - kind: "action", - value: "action:link-granola-note", - searchTerms: ["granola", "meeting", "notes", "link"], - title: activeGranolaLink - ? `Change linked Granola note (${activeGranolaLink.title})` - : "Link Granola note", - icon: , - run: async () => { - openGranolaNotePicker(composerThreadRef, "link"); - }, - }); - } - if (canLinkGranola && activeGranolaLink) { - actionItems.push({ - kind: "action", - value: "action:unlink-granola-note", - searchTerms: ["granola", "meeting", "notes", "unlink", "remove"], - title: `Unlink Granola note ${activeGranolaLink.title}`, - icon: , - run: async () => { - await unlinkGranolaNote(composerThreadRef); - }, - }); - } - const composerEnvironment = environments.find( - (environment) => environment.environmentId === composerThreadRef.environmentId, - ); - if (composerEnvironment?.serverConfig?.environment.capabilities.pullRequests === true) { - actionItems.push({ - kind: "action", - value: "action:attach-pull-request", - searchTerms: ["pull request", "pr", "attach", "context"], - title: "Attach pull request", - icon: , - run: async () => { - openPullRequestAttachPicker(composerThreadRef); - }, - }); - } - actionItems.push({ - kind: "action", - value: "action:attach-repository", - searchTerms: ["repository", "repo", "clone", "context", "ctxclone", "attach"], - title: "Attach repository", - icon: , - run: async () => { - openRepositoryAttachPicker(composerThreadRef); - }, - }); - } - if (activeThread !== null) { const thread = activeThread; actionItems.push({ @@ -2345,7 +1381,30 @@ function OpenCommandPaletteDialog(props: { title: "Restart agent session", icon: , run: async () => { - await restartAgentSession(scopeThreadRef(thread.environmentId, thread.id)); + const { environmentId } = thread; + if (thread.runtime !== null) { + const stopped = await stopThreadSession({ + environmentId, + input: { threadId: thread.id }, + }); + if (stopped._tag === "Failure") throw squashAtomCommandFailure(stopped); + } + toastManager.add({ + type: "success", + title: "Agent session will restart", + description: "Your next message starts a fresh session.", + }); + const project = projectByKey.get(`${environmentId}:${thread.projectId}`); + if (!project) return; + const refreshed = await refreshProviders({ + environmentId, + input: { + instanceId: thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId, + cwd: thread.worktreePath ?? project.workspaceRoot, + fresh: true, + }, + }); + if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); }, }); } @@ -2435,84 +1494,7 @@ function OpenCommandPaletteDialog(props: { }); } - const changeThemeItem: CommandPaletteSubmenuItem = { - kind: "submenu", - value: "action:change-theme", - searchTerms: ["change theme", "appearance", "colors", "palette"], - title: "Change theme", - icon: , - addonIcon: , - shortcutCommand: "theme.select", - groups: [ - { - value: "themes", - label: "Change theme", - items: themeCards.map(({ id, label, previews }) => ({ - kind: "action", - value: id === null ? "theme:standard" : `theme:palette:${id}`, - title: label, - description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined, - searchTerms: [label, "theme", "appearance"], - icon: , - titleTrailingContent: ( - - {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? ( - Current - ) : null} - - {previews.map((preview) => ( - - ))} - - - ), - run: async () => { - const saved = - previews.length === 1 && id !== null - ? setThemeHalf(previews[0]!.mode, id) - : setTheme(id ?? appearanceMode); - if (!saved) notifyThemeSaveFailure(); - }, - })), - }, - ], - }; actionItems.push(changeThemeItem); - - const changeAppearanceItem: CommandPaletteSubmenuItem = { - kind: "submenu", - value: "action:change-appearance", - searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"], - title: "Change appearance", - icon: , - addonIcon: , - shortcutCommand: "appearance.cycle", - groups: [ - { - value: "appearance", - label: "Change appearance", - items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({ - kind: "action", - value: `appearance:${mode}`, - title: label, - searchTerms: [label, "appearance", "mode"], - icon: , - titleTrailingContent: - appearanceMode === mode ? ( - Current - ) : undefined, - run: async () => { - if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); - }, - })), - }, - ], - }; actionItems.push(changeAppearanceItem); // The parent pushes an intent into this mounted dialog; applying it clears the @@ -2527,7 +1509,6 @@ function OpenCommandPaletteDialog(props: { setAddProjectCloneFlow(null); setNewProjectFlow(null); setLinkedThreadSearch(intent.kind === "search" ? intent : null); - setFromFilePicker(intent.kind === "search" && intent.fromFilePicker === true); dispatchSearch({ type: "reset", query: intent.kind === "search" ? intent.query : "" }); switch (intent.kind) { case "search": @@ -2538,7 +1519,7 @@ function OpenCommandPaletteDialog(props: { case "new-thread-in": pushPaletteView({ addonIcon: , - groups: [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }], + groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); return; case "change-theme": @@ -2547,92 +1528,17 @@ function OpenCommandPaletteDialog(props: { groups: [{ value: "themes", label: "Change theme", items: [] }], }); return; - case "run-scheduled-task": - pushPaletteView({ - addonIcon: , - groups: [{ value: "run-scheduled-task", label: "Scheduled tasks", items: [] }], - }); - return; - case "attention-inbox": - clearOpenIntent(); - browseNavigation.invalidate(); - cloneLookupGeneration.current += 1; - setIsRemoteProjectLookingUp(false); - setAddProjectCloneFlow(null); - dispatchSearch({ type: "query", query: "" }); - pushPaletteView({ - addonIcon: , - groups: [{ value: "attention-inbox", label: "Needs attention", items: [] }], - }); - return; } }); - const runScheduledTaskItemCount = runScheduledTaskItems.length; useLayoutEffect(() => { if (openIntent === null) return; // A new-thread-in intent waits until the project items exist. if (openIntent.kind === "new-thread-in" && projectThreadItemCount === 0) return; - if (openIntent.kind === "run-scheduled-task" && runScheduledTaskItemCount === 0) return; // oxlint-disable-next-line react/set-state-in-effect -- applying the intent dispatches to the parent reducer and can navigate, neither of which may run during render. applyOpenIntent(openIntent); - }, [openIntent, projectThreadItemCount, runScheduledTaskItemCount]); - - actionItems.push({ - kind: "action", - value: "action:theme-editor", - searchTerms: ["theme", "appearance", "colors", "palette", "customize"], - title: "Toggle theme editor", - icon: , - shortcutCommand: "themeEditor.toggle", - run: async () => { - toggleThemeEditorForTheme({ - theme, - themeHalves, - initialAppearance: resolvedTheme, - }); - }, - }); - - if (splitRouteRef) { - actionItems.push({ - kind: "action", - value: "action:split-view", - searchTerms: ["split", "side by side", "compare", "two", "pane", "tabs", "columns"], - title: splitViewOpen ? "Close split view" : "Open split view", - icon: , - shortcutCommand: "splitView.toggle", - run: async () => { - dispatchSplitViewAction("toggle"); - }, - }); - if (splitViewOpen) { - actionItems.push({ - kind: "action", - value: "action:split-view-focus-other", - searchTerms: ["split", "pane", "focus", "other", "switch"], - title: "Focus other split pane", - icon: , - shortcutCommand: "splitView.focusOther", - run: async () => { - dispatchSplitViewAction("focus-other"); - }, - }); - } - } + }, [openIntent, projectThreadItemCount]); - if (!clientSettings.legacySidebarEnabled) { - actionItems.push({ - kind: "action", - value: "action:sidebar-tabs", - searchTerms: ["tabs", "sidebar", "chat tabs", "group", "show", "hide"], - title: clientSettings.sidebarShowTabs ? "Hide tabs in sidebar" : "Show tabs in sidebar", - icon: , - shortcutCommand: "sidebar.toggleTabs", - run: async () => { - await updateClientSettings({ sidebarShowTabs: !clientSettings.sidebarShowTabs }); - }, - }); - } + actionItems.push(themeEditorItem); if ( environments.some( @@ -2651,67 +1557,6 @@ function OpenCommandPaletteDialog(props: { }); } - const attentionInboxItem: CommandPaletteSubmenuItem = { - kind: "submenu", - value: "action:attention-inbox", - searchTerms: [ - "needs attention", - "inbox", - "attention", - "approval", - "input", - "question", - "failed", - "completed", - "unread", - "review", - ], - title: "Needs attention", - ...(attentionEntries.length > 0 - ? { - titleTrailingContent: ( - - {attentionEntries.length} - - ), - } - : {}), - icon: , - addonIcon: , - shortcutCommand: "attentionInbox.open", - groups: [ - { - value: "attention-inbox", - label: "Needs attention", - items: attentionEntries.map((entry) => { - const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason]; - const projectTitle = attentionProjectTitleByKey.get( - `${entry.thread.environmentId}:${entry.thread.projectId}`, - ); - return { - kind: "action" as const, - value: `attention:${entry.key}`, - searchTerms: [entry.thread.title, label, projectTitle ?? ""], - title: entry.thread.title, - description: projectTitle ? `${label} · ${projectTitle}` : label, - timestamp: formatRelativeTimeLabel(entry.at), - icon: , - run: async () => { - // The entry's own route, not its tab group's last-opened tab. - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams( - scopeThreadRef(entry.thread.environmentId, entry.thread.id), - ), - }); - }, - }; - }), - }, - ], - }; - actionItems.push(attentionInboxItem); - actionItems.push({ kind: "action", value: "action:usage", @@ -2805,21 +1650,9 @@ function OpenCommandPaletteDialog(props: { ) : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups - : currentView?.groups[0]?.value === "attention-inbox" - ? attentionInboxItem.groups - : currentView?.groups[0]?.value === "appearance" - ? changeAppearanceItem.groups - : currentView?.groups[0]?.value === "new-thread-in" - ? [{ value: "new-thread-in", label: "Projects", items: projectThreadItems }] - : currentView?.groups[0]?.value === "run-scheduled-task" - ? [ - { - value: "run-scheduled-task", - label: "Scheduled tasks", - items: runScheduledTaskItems, - }, - ] - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, @@ -3278,18 +2111,6 @@ function OpenCommandPaletteDialog(props: { group.value === "directories" ? { ...group, label: "Select where to clone" } : group, ); - const remoteProjectContext = (() => { - if (addProjectCloneFlow?.step !== "confirm") { - return null; - } - - return { - title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, - description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, - icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), - }; - })(); - const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; const newProjectPathPreview = newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); @@ -3396,55 +2217,12 @@ function OpenCommandPaletteDialog(props: { titleTrailingContent: ( {newProjectPublishTargets.length > 1 ? ( - // The row toggles on click, so the picker keeps its clicks to itself. The - // row also cancels pointerdown to keep focus in the input, which drops the - // mousedown Select opens on, so the picker opens on click instead. - { - event.stopPropagation(); - // React bubbles the popup's clicks here through its portal; only the - // trigger opens the picker. - if (!event.currentTarget.contains(event.target as Node)) return; - if (!isCreatingNewProject) setNewProjectHostPickerOpen(true); - }} - > - - + ) : null} @@ -3809,106 +2587,56 @@ function OpenCommandPaletteDialog(props: { const inputAccessory = newProjectFlow !== null ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitNewProject(); - }} - /> - } - > - {isCreatingNewProject ? "Creating" : "Create"} - - {createShortcutLabel} - - - {`Create (${createShortcutLabel})`} - + { + void submitNewProject(); + }} + /> ) : addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - + { + void submitAddProjectCloneFlow(); + }} + /> ) : isBrowsing ? ( - - { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); - } - }} - /> - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {isCloneDestinationStep && !canCloneProject + - + : "This connection cannot add projects." + } + disabled={ + !canCreateProject || + !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || + relativePathNeedsActiveProject || + (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending)) + } + onSubmit={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> ) : null; const footerActionLabel = @@ -3990,38 +2718,21 @@ function OpenCommandPaletteDialog(props: { value={query} > {newProjectPathPreview !== null ? ( -
-
- - - - - - {newProjectName.length > 0 ? newProjectName : "New project"} - - - {newProjectName.length > 0 - ? `Creates ${newProjectPathPreview}` - : `Goes in ${newProjectsRoot}`} - {newProjectEnvironmentLabel === null ? null : ` on ${newProjectEnvironmentLabel}`} - - -
-
+ ) : null} - {remoteProjectContext ? ( -
-
Repository
-
- {remoteProjectContext.icon} - - {remoteProjectContext.title} - - {remoteProjectContext.description} - - -
-
+ {addProjectCloneFlow?.step === "confirm" ? ( + ) : null} {browseAccessError ? (
@@ -4054,42 +2765,10 @@ function OpenCommandPaletteDialog(props: { ? { emptyStateMessage: "Press Enter to create this folder and add it as a project.", } - : threadSearch.isPending + : isThreadSearchPending ? { emptyStateMessage: "Searching thread messages…" } : {})} /> ); } - -function ProjectSearchDescription(props: { - readonly environmentLabels: ReadonlyArray; - readonly grouped: boolean; - readonly location: { - readonly kind: "local" | "remote"; - readonly label: string; - readonly machine: EnvironmentMachineKind; - }; - readonly workspaceRoot: string; -}) { - if (!props.grouped) { - return ( - - - {props.location.kind === "remote" ? ( - - ) : null} - {props.location.label} - - - {props.workspaceRoot} - - ); - } - - return {props.environmentLabels.join(" · ")}; -} diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx new file mode 100644 index 000000000000..808536403a4b --- /dev/null +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -0,0 +1,171 @@ +import { + type AddProjectRemoteSource, + type NewProjectPublishTarget, +} from "@t3tools/client-runtime/operations/projects"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { FolderGit2Icon, LinkIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { sourceControlIcon } from "~/sourceControlPresentation"; +import { ITEM_ICON_CLASS } from "./CommandPalette.logic"; +import { CommandPaletteTitleIcon } from "./CommandPaletteResults"; +import { Button } from "./ui/button"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function remoteProjectSourceIcon( + source: AddProjectRemoteSource, + className: string, +): ReactNode { + if (source === "url") return ; + const Icon = sourceControlIcon(sourceControlClients.get(source)); + return ; +} + +export function sourceControlHostIcon( + definition: NewProjectPublishTarget["definition"], + className: string, +): ReactNode { + const Icon = sourceControlIcon(definition); + return ; +} + +/** The input's trailing submit button for the add-project, clone, and new-project flows. */ +export function CommandPaletteSubmitButton(props: { + readonly label: ReactNode; + readonly shortcutLabel: string; + readonly ariaLabel: string; + readonly tooltip: ReactNode; + readonly disabled: boolean; + readonly onSubmit: () => void; +}) { + return ( + + { + event.preventDefault(); + }} + onClick={props.onSubmit} + /> + } + > + {props.label} + + {props.shortcutLabel} + + + {props.tooltip} + + ); +} + +/** + * Picks which host a new project publishes to. The row it sits in toggles on click, so the + * picker keeps its clicks to itself. The row also cancels pointerdown to keep focus in the + * input, which drops the mousedown Select opens on, so the picker opens on click instead. + */ +export function NewProjectPublishHostPicker(props: { + readonly disabled: boolean; + readonly targets: ReadonlyArray; + readonly value: SourceControlProviderKind; + readonly onValueChange: (kind: SourceControlProviderKind) => void; +}) { + const [open, setOpen] = useState(false); + return ( + { + event.stopPropagation(); + // React bubbles the popup's clicks here through its portal; only the + // trigger opens the picker. + if (!event.currentTarget.contains(event.target as Node)) return; + if (!props.disabled) setOpen(true); + }} + > + + + ); +} + +export function NewProjectSummary(props: { + readonly name: string; + readonly pathPreview: string; + readonly projectsRoot: string | null; + readonly environmentLabel: string | null; +}) { + return ( +
+
+ + + + + + {props.name.length > 0 ? props.name : "New project"} + + + {props.name.length > 0 + ? `Creates ${props.pathPreview}` + : `Goes in ${props.projectsRoot}`} + {props.environmentLabel === null ? null : ` on ${props.environmentLabel}`} + + +
+
+ ); +} + +export function RemoteRepositorySummary(props: { + readonly source: AddProjectRemoteSource; + readonly title: string; + readonly description: string; +}) { + return ( +
+
Repository
+
+ + {remoteProjectSourceIcon(props.source, ITEM_ICON_CLASS)} + + + {props.title} + {props.description} + +
+
+ ); +} diff --git a/apps/web/src/components/CommandPaletteLookups.ts b/apps/web/src/components/CommandPaletteLookups.ts new file mode 100644 index 000000000000..2b6d6497c0cb --- /dev/null +++ b/apps/web/src/components/CommandPaletteLookups.ts @@ -0,0 +1,93 @@ +import { + type ClientSettings, + type EnvironmentId, + type ProjectId, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; + +import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { useClientSettings } from "../hooks/useSettings"; +import { getProjectOrderKey } from "../logicalProject"; +import { useProjects, useThreadShells } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; + +// Each helper reads its own source hook so the compiler caches the derived value on that +// source. Taking the source as an argument instead leaves the helper hook-free, and the +// compiler then treats it as a plain function and caches nothing. +export function useEnvironmentLookups() { + const { environments } = useEnvironments(); + const environmentLabelById = new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ); + const projectEnvironmentLocationById = new Map( + environments.map((environment) => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target); + return [ + environment.environmentId, + { + kind: isLocal ? "local" : "remote", + label: isPrimary ? "Local" : isLocal ? `${environment.label} (Local)` : environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + }, + ] as const; + }), + ); + return { environmentLabelById, projectEnvironmentLocationById }; +} + +export function useProjectLookups() { + const projects = useProjects(); + const projectCwdById = new Map( + projects.map((project) => [project.id, project.workspaceRoot]), + ); + const projectByKey = new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project]), + ); + const projectTitleById = new Map( + projects.map((project) => [project.id, project.title]), + ); + return { projectCwdById, projectByKey, projectTitleById }; +} + +export const selectCommandPaletteClientSettings = (settings: ClientSettings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + sidebarProjectSortOrder: settings.sidebarProjectSortOrder, + sidebarThreadSortOrder: settings.sidebarThreadSortOrder, +}); + +export function useSortedProjectGroups(environmentLabelById: ReadonlyMap) { + const projects = useProjects(); + const threads = useThreadShells(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const clientSettings = useClientSettings(selectCommandPaletteClientSettings); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = { + sidebarProjectGroupingMode: clientSettings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: clientSettings.sidebarProjectGroupingOverrides, + }; + const orderedProjects = orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + const unsortedProjectGroups = buildSidebarProjectSnapshots({ + projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }); + return sortLogicalProjectsForSidebar( + unsortedProjectGroups, + threads, + clientSettings.sidebarProjectSortOrder, + ); +} diff --git a/apps/web/src/components/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx new file mode 100644 index 000000000000..90373413be4f --- /dev/null +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -0,0 +1,117 @@ +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; + +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; +import { + buildCommandPaletteProjectMetadata, + buildProjectActionItems, + type CommandPaletteProject, + ITEM_ICON_CLASS, +} from "./CommandPalette.logic"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot } from "./ThreadCommandSubtitle"; + +export interface CommandPaletteProjectLocation { + readonly kind: "local" | "remote"; + readonly label: string; + readonly machine: EnvironmentMachineKind; +} + +const UNKNOWN_PROJECT_LOCATION: CommandPaletteProjectLocation = { + kind: "remote", + label: "Remote", + machine: "server", +}; + +interface ProjectItemsInput { + readonly projects: ReadonlyArray; + readonly projectGroupByTargetKey: ReadonlyMap; + readonly locationByEnvironmentId: ReadonlyMap; + readonly runProject: (project: CommandPaletteProject) => Promise; +} + +function projectFaviconIcon(project: Project) { + return ; +} + +/** Projects as root search results; a grouped project matches on every member. */ +export function buildProjectSearchItems(input: ProjectItemsInput) { + const membersOf = (project: CommandPaletteProject) => + input.projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`)?.memberProjects ?? [ + project, + ]; + return buildProjectActionItems({ + projects: input.projects, + valuePrefix: "project", + searchTerms: (project) => + buildCommandPaletteProjectMetadata({ + projects: membersOf(project), + locationByEnvironmentId: input.locationByEnvironmentId, + }).searchTerms, + renderDescription: (project) => { + const members = membersOf(project); + const metadata = buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: input.locationByEnvironmentId, + }); + return members.length > 1 ? ( + {metadata.environmentLabels.join(" · ")} + ) : ( + + ); + }, + icon: projectFaviconIcon, + runProject: input.runProject, + }); +} + +/** Projects as targets of the "New thread in..." submenu. */ +export function buildNewThreadInProjectItems(input: ProjectItemsInput) { + return buildProjectActionItems({ + projects: input.projects, + valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = input.projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const location = input.locationByEnvironmentId.get(project.environmentId); + return [ + ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []), + ...(location ? [location.label] : []), + ]; + }, + renderDescription: (project) => ( + + ), + icon: projectFaviconIcon, + runProject: input.runProject, + }); +} + +function ProjectLocationDescription(props: { + readonly location: CommandPaletteProjectLocation | undefined; + readonly workspaceRoot: string; +}) { + const location = props.location ?? UNKNOWN_PROJECT_LOCATION; + return ( + + + {location.kind === "remote" ? ( + + ) : null} + {location.label} + + + {props.workspaceRoot} + + ); +} diff --git a/apps/web/src/components/CommandPaletteThemeItems.tsx b/apps/web/src/components/CommandPaletteThemeItems.tsx new file mode 100644 index 000000000000..818229d95066 --- /dev/null +++ b/apps/web/src/components/CommandPaletteThemeItems.tsx @@ -0,0 +1,163 @@ +import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { MonitorIcon, MoonIcon, PaletteIcon, SunIcon } from "lucide-react"; + +import { useCustomThemes } from "../hooks/useCustomThemes"; +import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; +import { useTheme } from "../hooks/useTheme"; +import { getThemeDefinition } from "../themePalette"; +import { + ADDON_ICON_CLASS, + ITEM_ICON_CLASS, + type CommandPaletteActionItem, + type CommandPaletteSubmenuItem, +} from "./CommandPalette.logic"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { + STANDARD_THEME_CARDS, + getThemeCardDefinition, + ThemePreviewCircle, +} from "./settings/ThemePreviewCircles"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +export const APPEARANCE_OPTIONS = [ + { mode: "system", label: "System", icon: MonitorIcon }, + { mode: "light", label: "Light", icon: SunIcon }, + { mode: "dark", label: "Dark", icon: MoonIcon }, +] as const; + +export function notifyThemeSaveFailure(): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save theme selection", + description: "Try again.", + }), + ); +} + +// Reads its own source hooks so the compiler caches the cards on them. Taking the +// sources as arguments would leave it hook-free, and the compiler would cache nothing. +function useThemeCards() { + const customThemes = useCustomThemes(); + const environmentThemes = useEnvironmentThemeDefinitions(); + const seen = new Set(); + return [ + ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), + ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] + .filter((definition) => { + if (seen.has(definition.id)) return false; + seen.add(definition.id); + return true; + }) + .map(getThemeCardDefinition), + ]; +} + +export function useCommandPaletteThemeItems() { + const { + theme, + themeHalves, + resolvedTheme, + appearanceMode, + setAppearanceMode, + setTheme, + setThemeHalf, + } = useTheme(); + const themeCards = useThemeCards(); + + const changeThemeItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-theme", + searchTerms: ["change theme", "appearance", "colors", "palette"], + title: "Change theme", + icon: , + addonIcon: , + shortcutCommand: "theme.select", + groups: [ + { + value: "themes", + label: "Change theme", + items: themeCards.map(({ id, label, previews }) => ({ + kind: "action", + value: id === null ? "theme:standard" : `theme:palette:${id}`, + title: label, + description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined, + searchTerms: [label, "theme", "appearance"], + icon: , + titleTrailingContent: ( + + {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? ( + Current + ) : null} + + {previews.map((preview) => ( + + ))} + + + ), + run: async () => { + const saved = + previews.length === 1 && id !== null + ? setThemeHalf(previews[0]!.mode, id) + : setTheme(id ?? appearanceMode); + if (!saved) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + + const changeAppearanceItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-appearance", + searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"], + title: "Change appearance", + icon: , + addonIcon: , + shortcutCommand: "appearance.cycle", + groups: [ + { + value: "appearance", + label: "Change appearance", + items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({ + kind: "action", + value: `appearance:${mode}`, + title: label, + searchTerms: [label, "appearance", "mode"], + icon: , + titleTrailingContent: + appearanceMode === mode ? ( + Current + ) : undefined, + run: async () => { + if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + + const themeEditorItem: CommandPaletteActionItem = { + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }; + + return { changeThemeItem, changeAppearanceItem, themeEditorItem }; +} diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx new file mode 100644 index 000000000000..e2732f541908 --- /dev/null +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -0,0 +1,150 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { MessageSquareIcon } from "lucide-react"; + +import { useClientSettings } from "../hooks/useSettings"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + type ProviderInstanceEntry, +} from "../providerInstances"; +import { useThreadShells } from "../state/entities"; +import { + useEnvironmentProviders, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; +import { useThreadSearch } from "../state/queries"; +import { primaryServerProvidersAtom } from "../state/server"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { buildThreadActionItems, ITEM_ICON_CLASS } from "./CommandPalette.logic"; +import { useEnvironmentLookups, useProjectLookups } from "./CommandPaletteLookups"; +import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle"; +import { + ThreadRowLeadingStatus, + ThreadRowTrailingStatus, + ThreadSearchPullRequestNumber, +} from "./ThreadStatusIndicators"; + +function useProviderEntryByEnvironmentAndInstanceId() { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const providers = useAtomValue(primaryServerProvidersAtom); + const providersByEnvironment = useEnvironmentProviders(); + return new Map( + environments.flatMap((environment) => { + const serverConfig = environment.serverConfig; + const derived = deriveProviderInstanceEntries( + providersByEnvironment.get(environment.environmentId) ?? + (environment.environmentId === primaryEnvironmentId ? providers : []), + ); + const entries = serverConfig + ? applyProviderInstanceSettings(derived, serverConfig.settings, null) + : derived; + return entries.map( + (entry) => [`${environment.environmentId}:${entry.instanceId}`, entry] as const, + ); + }), + ); +} + +function useThreadSearchWithContentMatches(query: string) { + const { environments } = useEnvironments(); + const environmentIds: ReadonlyArray = environments + .filter((environment) => environment.connection.phase === "connected") + .map((environment) => environment.environmentId); + const threadSearch = useThreadSearch(environmentIds, query); + const contentMatchByKey = new Map( + threadSearch.matches.flatMap((match) => + match.source === "user" || match.source === "assistant" + ? [[threadSearchMatchKey(match), match] as const] + : [], + ), + ); + return { threadSearch, contentMatchByKey }; +} + +/** Every thread as a palette item, with message matches for a non-empty `threadSearchQuery`. */ +export function useCommandPaletteThreadItems(input: { + readonly activeThreadId: ThreadId | undefined; + readonly threadSearchQuery: string; +}) { + const { activeThreadId, threadSearchQuery } = input; + const navigate = useNavigate(); + const threads = useThreadShells(); + const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); + const { projectEnvironmentLocationById } = useEnvironmentLookups(); + const providerEntryByEnvironmentAndInstanceId = useProviderEntryByEnvironmentAndInstanceId(); + const { threadSearch, contentMatchByKey } = useThreadSearchWithContentMatches(threadSearchQuery); + const { projectByKey, projectTitleById } = useProjectLookups(); + + const threadItems = buildThreadActionItems({ + threads, + ...(activeThreadId ? { activeThreadId } : {}), + projectTitleById, + sortOrder: sidebarThreadSortOrder, + icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => ( + <> + + + + ), + renderDescription: (thread, { projectTitle }) => { + const modelInstanceId = + thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = + providerEntryByEnvironmentAndInstanceId.get(`${thread.environmentId}:${modelInstanceId}`) ?? + null; + return ( + + ); + }, + getContentMatch: (thread) => { + const match = contentMatchByKey.get( + threadSearchMatchKey({ + environmentId: thread.environmentId, + threadId: thread.id, + }), + ); + return match && (match.source === "user" || match.source === "assistant") + ? { + source: match.source, + snippet: match.snippet, + query: threadSearch.query, + } + : undefined; + }, + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }); + + return { threadItems, isSearchPending: threadSearch.isPending }; +} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 64b192a860d8..9d1f37d1d7e2 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -3,21 +3,13 @@ import { serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; -import { act, createRef, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { createRef, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { findAssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; - -// No router is mounted; the citation chip only needs navigation to exist. -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useNavigate: () => async () => {}, - Link: (({ children }: { children: ReactNode }) =>
{children}) as never, -})); - +import { act, render } from "../../test/render"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; const citation = { @@ -33,20 +25,6 @@ const citation = { }; const promptBefore = `See ${serializeAssistantCitation(citation)} `; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - function Composer({ question, editorRef, @@ -91,9 +69,9 @@ function Composer({ it("keeps an unsaved citation comment when a question takes the composer", async () => { const editorRef = createRef(); - const drafts = () => container.querySelector("output")?.dataset; + await using screen = await render(); + const drafts = () => screen.container.querySelector("output")?.dataset; - await act(async () => root.render()); const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); if (!sourceAnchor) throw new Error("Citation source did not resolve"); await act(async () => @@ -110,9 +88,9 @@ it("keeps an unsaved citation comment when a question takes the composer", async await comment.click(); await userEvent.keyboard("check this"); - await act(async () => root.render()); + await screen.rerender(); expect(drafts()?.answer).toBe(""); - await act(async () => root.render()); + await screen.rerender(); expect(drafts()?.prompt).toBe( `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `, @@ -142,8 +120,8 @@ it("sends only the citation and leaves the rest of the composer", async () => { localStorage.removeItem("t3code:citation-comment-submit:v1"); const editorRef = createRef(); const send = vi.fn(async (_citationText: string) => true); - await act(async () => - root.render(), + await using screen = await render( + , ); await openCitationComment(editorRef); @@ -152,22 +130,22 @@ it("sends only the citation and leaves the rest of the composer", async () => { expect(send).toHaveBeenCalledExactlyOnceWith( serializeAssistantCitation(withAssistantCitationComment(citation, "check this")), ); - await expect.poll(() => container.querySelector("output")?.dataset.prompt).toBe("See "); + await expect.poll(() => screen.container.querySelector("output")?.dataset.prompt).toBe("See "); }); it("keeps the commented citation in the composer when its send fails", async () => { localStorage.removeItem("t3code:citation-comment-submit:v1"); const editorRef = createRef(); const send = vi.fn(async (_citationText: string) => false); - await act(async () => - root.render(), + await using screen = await render( + , ); await openCitationComment(editorRef); await act(async () => userEvent.keyboard("{Enter}")); expect(send).toHaveBeenCalledOnce(); - expect(container.querySelector("output")?.dataset.prompt).toBe( + expect(screen.container.querySelector("output")?.dataset.prompt).toBe( `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `, ); }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index c6c64f6b113c..668e9520f9e9 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -7,14 +7,13 @@ import { useRetainedContextPayloads, } from "./chat/composerContextUndo"; -import { userEvent } from "vite-plus/test/browser"; import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; -import { act, createElement, createRef, useEffect, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { createElement, createRef, useEffect, useState } from "react"; +import { describe, expect, it } from "vite-plus/test"; + +import { act, render } from "../../test/render"; -import { detectComposerTrigger } from "~/composer-logic"; import { serializeEditorDoc } from "~/composer-rich-text-doc"; import { ComposerPromptEditor, @@ -22,22 +21,6 @@ import { type ComposerPromptEditorProps, } from "./ComposerPromptEditor"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - const babysit: ServerProviderSkill = { name: "babysit", path: "/skills/babysit/SKILL.md", @@ -75,27 +58,39 @@ function Composer({ ); } -async function mountEditor( +function composer( value: string, richTextEnabled = true, skills: ReadonlyArray = [], onChange?: ComposerPromptEditorProps["onChange"], ) { - await act(async () => - root.render( - , - ), + return ( + ); +} + +function editorIn(container: HTMLElement) { const element = container.querySelector(".tiptap"); if (!element) throw new Error("Composer did not mount"); return element.editor; } +async function mountEditor(...props: Parameters) { + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(composer(...props))); + const editor = editorIn(screen.container); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), + editor, + /** Rerenders the same composer with new props, as a parent would. */ + update: async (...next: Parameters) => { + await screen.rerender(composer(...next)); + return editorIn(screen.container); + }, + }); +} + async function replaceDomText(editor: Editor, text: string, isComposing = false) { await act(async () => { const node = editor.view.dom.querySelector("p")?.firstChild; @@ -140,7 +135,8 @@ it.each([ { value: "clean _build and node", code: [] }, { value: "clean `_build` and `node", code: ["_build"] }, ])("keeps a typed underscore literal after $value", async ({ value, code }) => { - const editor = await mountEditor(value); + await using screen = await mountEditor(value); + const { editor } = screen; await act(async () => { editor.commands.focus("end"); const { from, to } = editor.state.selection; @@ -151,22 +147,12 @@ it.each([ expect(textWithMark(editor, "code")).toEqual(code); }); -it("still italicizes text when typing a closing asterisk", async () => { - const editor = await mountEditor("clean *build"); - await act(async () => { - editor.commands.focus("end"); - const { from, to } = editor.state.selection; - typeText(editor, from, to, "*"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("clean *build*"); - expect(textWithMark(editor, "italic")).toEqual(["build"]); -}); - it.each([false, true])( "composes an umlaut without wrapping the preedit text (rich text: %s)", async (richTextEnabled) => { // The middle dot stands in for the provisional text shown by the Compose key. - const editor = await mountEditor("·", richTextEnabled); + await using screen = await mountEditor("·", richTextEnabled); + const { editor } = screen; await act(async () => { editor.view.focus(); editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); @@ -181,19 +167,9 @@ it.each([false, true])( }, ); -it("commits a composed quote without surrounding the preedit text", async () => { - const editor = await mountEditor("·"); - await act(async () => { - editor.view.focus(); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); - editor.view.dom.dispatchEvent(new CompositionEvent("compositionend", { data: '"' })); - }); - await replaceDomText(editor, '"'); - expect(serializeEditorDoc(editor.state.doc).value).toBe('"'); -}); - it("lets composition replace selected text without surrounding it", async () => { - const editor = await mountEditor("word"); + await using screen = await mountEditor("word"); + const { editor } = screen; await act(async () => { editor.commands.setTextSelection({ from: 1, to: 5 }); editor.view.dom.dispatchEvent(new CompositionEvent("compositionstart")); @@ -202,31 +178,10 @@ it("lets composition replace selected text without surrounding it", async () => expect(serializeEditorDoc(editor.state.doc).value).toBe('"'); }); -it("replaces text outside the selected range without surrounding it", async () => { - const editor = await mountEditor("word"); - await act(async () => { - editor.commands.setTextSelection({ from: 1, to: 5 }); - typeText(editor, 2, 3, '"'); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe('w"rd'); -}); - -it.each([false, true])( - "still surrounds selected text with quotes (rich text: %s)", - async (richTextEnabled) => { - const editor = await mountEditor("word", richTextEnabled); - await act(async () => { - editor.commands.setTextSelection({ from: 1, to: 5 }); - editor.view.focus(); - }); - await replaceDomText(editor, '"'); - expect(serializeEditorDoc(editor.state.doc).value).toBe('"word"'); - }, -); - it("keeps unknown dollar names editable when restoring and pasting a draft", async () => { const value = "echo $HOME $PATH $anything $notaskill $20 $20k $babysit "; - const editor = await mountEditor(value, true, [babysit]); + await using screen = await mountEditor(value, true, [babysit]); + const { editor } = screen; expect(editor.state.doc.textContent).toBe(value.replace("$babysit", "")); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); await act(async () => { @@ -244,56 +199,19 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy expect(serializeEditorDoc(editor.state.doc).value).toBe(value); }); -it("keeps skill queries active before whitespace and line breaks, suppressing only real chips", async () => { - const onChange = vi.fn(); - const editor = await mountEditor("use now\nfirst \n$babysit tail", true, [babysit], onChange); - const trigger = () => { - const [value, , expandedCursor, suppressTrigger] = onChange.mock.lastCall!; - return suppressTrigger ? null : detectComposerTrigger(value, expandedCursor); - }; - await act(async () => { - editor.commands.setTextSelection("use ".length + 1); - typeText(editor, editor.state.selection.from, editor.state.selection.to, "$bab"); - }); - expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); - await act(async () => { - editor.commands.setTextSelection(editor.state.doc.child(0).nodeSize + 1 + "first ".length); - typeText(editor, editor.state.selection.from, editor.state.selection.to, "$bab"); - }); - expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); - await act(async () => { - editor.commands.setHardBreak(); - }); - await act(async () => { - editor.commands.setTextSelection(editor.state.selection.from - 1); - }); - expect(editor.state.selection.$from.nodeAfter?.type.name).toBe("hardBreak"); - expect.soft(trigger()).toMatchObject({ kind: "skill", query: "bab" }); - - const chip = serializeEditorDoc(editor.state.doc).runs.find((run) => run.kind === "token")!; - await act(async () => { - editor.commands.setTextSelection(chip.pmPos); - }); - expect(onChange.mock.lastCall![3]).toBe(true); - await act(async () => { - editor.commands.setTextSelection(chip.pmPos + 1); - }); - expect(onChange.mock.lastCall![3]).toBe(true); - expect(trigger()).toBeNull(); -}); - it("keeps the caret inside unknown dollar text across rich and plain remounts", async () => { const value = "echo $HOME now"; - let editor = await mountEditor(value); + await using screen = await mountEditor(value); + let { editor } = screen; await act(async () => { editor.commands.setTextSelection("echo $HO".length + 1); }); - editor = await mountEditor(value, false); + editor = await screen.update(value, false); await act(async () => { editor.commands.insertContent("X"); }); expect(serializeEditorDoc(editor.state.doc).value).toBe("echo $HOXME now"); - editor = await mountEditor(value, true); + editor = await screen.update(value, true); await act(async () => { editor.commands.insertContent("Y"); }); @@ -304,18 +222,19 @@ it("reconciles provider skills and metadata without losing formatting, caret, or const value = "run **use $babysit now** then $HOME"; const disabled = { ...babysit, enabled: false }; const agentOnly = { ...babysit, userInvocable: false }; - const editor = await mountEditor(value, true, [disabled]); + await using screen = await mountEditor(value, true, [disabled]); + let editor = screen.editor; expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); - await mountEditor(value, true, [agentOnly]); + editor = await screen.update(value, true, [agentOnly]); expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); await act(async () => { editor.commands.setTextSelection("run use $baby".length + 1); }); - await mountEditor(value, true, [disabled, babysit]); + editor = await screen.update(value, true, [disabled, babysit]); expect(editor.state.doc.textContent).toBe("run use now then $HOME"); const chip = serializeEditorDoc(editor.state.doc).runs.find((run) => run.kind === "token")!; expect(editor.state.selection.from).toBe(chip.pmPos + 1); - await mountEditor(value, true, [disabled]); + editor = await screen.update(value, true, [disabled]); expect.soft(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); await act(async () => { @@ -328,25 +247,27 @@ it("reconciles provider skills and metadata without losing formatting, caret, or expect(serializeEditorDoc(editor.state.doc).value).toBe(value); expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); - await mountEditor(value, true, [babysit]); + editor = await screen.update(value, true, [babysit]); await act(async () => { editor.commands.deleteRange({ from: chip.pmPos, to: chip.pmPos + 1 }); }); - await mountEditor(value, true, [agentOnly]); + editor = await screen.update(value, true, [agentOnly]); await act(async () => { editor.commands.undo(); }); expect(editor.state.doc.textContent).toBe("run use $babysit now then $HOME"); expect(serializeEditorDoc(editor.state.doc).value).toBe(value); - await mountEditor(value, true, [{ ...babysit, description: "Previous provider description" }]); + editor = await screen.update(value, true, [ + { ...babysit, description: "Previous provider description" }, + ]); await act(async () => { - container + screen.container .querySelector('[aria-label="Skill, Babysit. Show details"]')! .click(); }); expect(document.body.textContent).toContain("Previous provider description"); - await mountEditor(value, true, [ + editor = await screen.update(value, true, [ { ...disabled, displayName: "Disabled Babysit" }, { ...babysit, @@ -354,11 +275,11 @@ it("reconciles provider skills and metadata without losing formatting, caret, or shortDescription: " Current short description ", }, ]); - expect(container.textContent).toContain("Current Babysit"); + expect(screen.container.textContent).toContain("Current Babysit"); expect(document.body.textContent).toContain("Current short description"); expect(document.body.textContent).not.toContain("Previous provider description"); - await mountEditor(value, true, [{ ...babysit, displayName: "Current Babysit" }]); - expect(container.textContent).toContain("Current Babysit"); + editor = await screen.update(value, true, [{ ...babysit, displayName: "Current Babysit" }]); + expect(screen.container.textContent).toContain("Current Babysit"); expect(document.body.textContent).not.toContain("Current short description"); expect(document.body.textContent).toContain("No description is available for this skill."); expect(document.body.textContent).not.toContain("Previous provider description"); @@ -431,18 +352,14 @@ describe("composer context undo lifetime", () => { return createElement(ComposerPromptEditor, editorProps); } - const render = async (targetKey: string) => { - await act(async () => root.render(createElement(Composer, { targetKey }))); - }; - const editor = () => { - const element = container.querySelector(".tiptap"); - if (!element) throw new Error("Composer did not mount"); - return element.editor; - }; - await render("a"); - return { + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(createElement(Composer, { targetKey: "a" }))); + const editor = () => editorIn(screen.container); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), initialValue, - render, + show: (targetKey: string) => screen.rerender(createElement(Composer, { targetKey })), editor, draft: () => current, retained: () => retained, @@ -459,68 +376,43 @@ describe("composer context undo lifetime", () => { editor().commands.undo(); }); }, - }; - } - - it("preserves typing focus across draft switches without taking focus from another control", async () => { - const composer = await mountComposer("Draft B"); - const previousEditor = composer.editor(); - await act(async () => { - previousEditor.commands.setTextSelection(previousEditor.state.doc.content.size - 1); - previousEditor.view.focus(); }); - expect(document.activeElement).toBe(previousEditor.view.dom); - - await composer.render("b"); - const nextEditor = composer.editor(); - expect(nextEditor).not.toBe(previousEditor); - expect(document.activeElement).toBe(nextEditor.view.dom); - await act(async () => userEvent.keyboard("x")); - expect(composer.draft().value).toBe("xDraft B"); - - const button = document.createElement("button"); - container.append(button); - button.focus(); - await composer.render("a"); - expect(document.activeElement).toBe(button); - expect(composer.draft().value).toBe(composer.initialValue); - }); + } it("restores a deleted chip and its file bytes on same-draft undo, then releases them on unmount", async () => { - const composer = await mountComposer(); + await using composer = await mountComposer(); await composer.deleteDraft(); expect(composer.draft()).toEqual({ value: "", files: [] }); const retained = composer.retained(); expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); - await composer.render("a"); + await composer.show("a"); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(composer.initialValue); expect(composer.draft()).toEqual({ value: composer.initialValue, files: [file] }); expect(composer.draft().files[0]?.file).toBe(file.file); await composer.deleteDraft(); - await act(async () => root.unmount()); + await composer.unmount(); expect(retained.files.size).toBe(0); - root = createRoot(container); }); it.each(["", "Draft B"])( "releases deleted payloads and prevents undo into the previous draft when switching to %j", async (destinationValue) => { - const composer = await mountComposer(destinationValue); + await using composer = await mountComposer(destinationValue); await composer.deleteDraft(); expect(composer.draft()).toEqual({ value: "", files: [] }); const retained = composer.retained(); expect(retained.files.get(fileContextReference(file).contextId)?.file).toBe(file.file); - await composer.render("b"); + await composer.show("b"); expect(retained.files.size).toBe(0); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(destinationValue); expect(composer.draft()).toEqual({ value: destinationValue, files: [] }); - await composer.render("a"); + await composer.show("a"); await composer.undo(); expect(serializeEditorDoc(composer.editor().state.doc).value).toBe(""); expect(composer.draft()).toEqual({ value: "", files: [] }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx deleted file mode 100644 index 3f9f6ccbb23e..000000000000 --- a/apps/web/src/components/ComposerPromptEditorTiptap.navigation.browser.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import type { Editor } from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; -import { act, createRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { serializeEditorDoc } from "~/composer-rich-text-doc"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; - -const draft = - "> **Assistant and reasoning text still save intermediate full snapshots**, using the existing 50 ms batching. Compaction still preserves their `turn-item.updated` events. [Code](/Users/yashsingh/p/projects/t3code/apps/server/src/orchestration-v2/ProjectionMaintenance.ts:235)"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -async function mountEditor(value: string, richTextEnabled = true) { - await act(async () => - root.render( - ()} - value={value} - cursor={0} - richTextEnabled={richTextEnabled} - contextRecords={new Map()} - skills={[]} - disabled={false} - placeholder="" - onChange={() => {}} - onPaste={() => {}} - />, - ), - ); - const element = container.querySelector(".tiptap"); - if (!element) throw new Error("Composer did not mount"); - return element.editor; -} - -function press(editor: Editor, key: string, options: KeyboardEventInit = {}) { - const event = new KeyboardEvent("keydown", { key, cancelable: true, ...options }); - const handled = editor.view.props.handleKeyDown?.(editor.view, event); - expect(event.defaultPrevented).toBe(Boolean(handled)); - return handled; -} - -it("keeps Up before the opening bold marker in a quote, including while typing", async () => { - const editor = await mountEditor(draft); - const position = editor.state.selection.from; - await act(async () => { - editor.view.focus(); - expect(press(editor, "ArrowUp")).toBe(true); - expect(editor.state.selection.from).toBe(position); - editor.view.updateState(editor.state); - const marker = editor.view.dom.querySelector(".composer-rich-marker"); - const caret = window.getSelection()?.getRangeAt(0); - if (!marker || !caret) throw new Error("Caret or bold marker is missing"); - const markerRange = document.createRange(); - markerRange.selectNode(marker); - expect(caret.compareBoundaryPoints(Range.START_TO_START, markerRange)).toBeLessThanOrEqual(0); - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> x**")); -}); - -it("keeps Down after a closing marker, including while typing", async () => { - const editor = await mountEditor("> **bold**"); - await act(async () => { - editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); - expect(press(editor, "ArrowDown")).toBe(true); - expect(press(editor, "ArrowDown")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("> **bold**x"); -}); - -it("leaves vertical navigation between paragraphs to the browser", async () => { - const editor = await mountEditor("**first**\n**second**"); - await act(async () => { - const first = editor.state.doc.child(0); - editor.commands.setTextSelection(first.nodeSize + 1); - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.setTextSelection(first.nodeSize - 1); - expect(press(editor, "ArrowDown")).toBe(false); - }); -}); - -it.each([{ shiftKey: true }, { altKey: true }, { ctrlKey: true }, { isComposing: true }])( - "preserves modified Up behavior for %j", - async (options) => { - const editor = await mountEditor(draft); - await act(async () => { - expect(press(editor, "ArrowUp", options)).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); - }, -); - -describe("macOS boundary navigation", () => { - beforeEach(() => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - container.style.width = "600px"; - }); - - const markdown = String.raw`Fixed the top padding to 16px and updated the offline index and ZIP. - -**Yes: [HW08, problem 6, page 2](/Users/yashsingh/.t3/scratch/2026-10-16-gather-all-info-about-the-2fcd52f5/calculus-resources/files/HW08.pdf)** asks for the absolute maximum and minimum of -\[ -f(x,y)=2x^2+y^2,\qquad x^2+y^2\le16. -\] -That's exactly a disk with the circular boundary \(x^2+y^2=16\), and it is within section 14.7. - -The original Fall 2022, 2023, and 2024 sample exams also have circle-constrained extrema problems, all question 6. Those explicitly require Lagrange multipliers, so they were removed from your filtered copies. **HW08 #6 is the best match for your exam.**`; - - it.each(["ArrowRight", "ArrowDown"])( - "keeps Command-%s after the closing marker in the pasted draft", - async (key) => { - const editor = await mountEditor(markdown); - await act(async () => { - editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); - editor.view.focus(); - expect(press(editor, "ArrowRight")).toBe(true); - expect(press(editor, key, { metaKey: true })).toBe(true); - expect(editor.state.selection.eq(TextSelection.atEnd(editor.state.doc))).toBe(true); - // Force the DOM selection rewrite that used to pull the caret back. - editor.view.updateState(editor.state); - const markers = editor.view.dom.querySelectorAll(".composer-rich-marker"); - const closingMarker = markers[markers.length - 1]; - const caret = window.getSelection()?.getRangeAt(0); - if (!closingMarker || !caret) throw new Error("Caret or closing marker is missing"); - const markerRange = document.createRange(); - markerRange.selectNode(closingMarker); - expect(caret.compareBoundaryPoints(Range.START_TO_END, markerRange)).toBeGreaterThanOrEqual( - 0, - ); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(markdown + "x"); - }, - ); - - it.each(["ArrowRight", "ArrowDown"])( - "moves Command-%s from inside bold to outside its closing marker", - async (key) => { - const editor = await mountEditor("**bold**"); - await act(async () => { - editor.commands.setTextSelection(3); - editor.view.focus(); - expect(press(editor, key, { metaKey: true })).toBe(true); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("**bold**x"); - }, - ); - - it.each(["ArrowLeft", "ArrowUp"])("moves Command-%s before the opening marker", async (key) => { - const editor = await mountEditor("**bold**"); - await act(async () => { - editor.commands.setTextSelection(3); - editor.view.focus(); - expect(press(editor, key, { metaKey: true })).toBe(true); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("x**bold**"); - }); - - it.each(["ArrowRight", "ArrowDown"])( - "extends Shift-Command-%s to the end without collapsing the selection", - async (key) => { - const editor = await mountEditor("**bold**"); - await act(async () => { - editor.commands.setTextSelection(3); - editor.view.focus(); - expect(press(editor, key, { metaKey: true, shiftKey: true })).toBe(true); - expect(editor.state.selection.anchor).toBe(3); - expect(editor.state.selection.head).toBe(TextSelection.atEnd(editor.state.doc).to); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe("**box**"); - }, - ); - - it("leaves Command-arrow navigation to the browser on other platforms", async () => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); - const editor = await mountEditor("**bold**"); - await act(async () => { - editor.view.focus(); - expect(press(editor, "ArrowDown", { metaKey: true })).toBe(false); - }); - }); -}); - -it("leaves literal Markdown navigation to the browser in plain mode", async () => { - const editor = await mountEditor(draft, false); - await act(async () => { - editor.commands.setTextSelection(5); - expect(press(editor, "ArrowUp")).toBe(false); - editor.commands.insertContent("x"); - }); - expect(serializeEditorDoc(editor.state.doc).value).toBe(draft.replace("> **", "> **x")); -}); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx deleted file mode 100644 index f1d5a7a12238..000000000000 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import "~/index.css"; - -import { RegistryContext, useAtomValue } from "@effect/atom-react"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - EnvironmentId, - RunId, - ThreadId, - type ReviewDiffPreviewResult, - type ReviewDiffPreviewSource, -} from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import * as DateTime from "effect/DateTime"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; - -vi.mock("../hooks/useThreadRouteTarget", () => ({ - useThreadRouteRef: () => threadRef, - useThreadRouteTarget: () => ({ ...threadRef, kind: "thread" }), -})); -vi.mock("../state/entities", () => ({ - useQuietThreadShell: () => thread, - useThreadCheckpointSummaries: () => turns, - useProject: () => null, -})); -vi.mock("../state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true }) })); -vi.mock("../state/server", () => ({ serverEnvironment: { configValueAtom: () => configAtom } })); -vi.mock("../state/vcs", () => ({ vcsEnvironment: { status: () => null, listRefs: () => null } })); -vi.mock("../state/review", () => ({ - reviewEnvironment: { - diffPreview: () => previewAtom, - diffFilePatch: ({ input }: { input: { request: { file: { path: string } } } }) => - patchAtoms(input.request.file.path), - }, -})); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../hooks/useSettings", () => ({ - useClientSettings: () => ({ - ...DEFAULT_CLIENT_SETTINGS, - diffFilesCollapsed: useAtomValue(collapsedAtom), - }), - updateClientSettings: vi.fn(), -})); -vi.mock("../hooks/useTheme", () => ({ - useRenderedAppearance: () => "dark", - useTheme: () => ({ resolvedTheme: "dark" }), -})); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); -vi.mock("../fileContextMenu", () => ({ useFileContextMenuHandler: () => vi.fn() })); -vi.mock("../lib/checkpointDiffState", () => ({ - useCheckpointDiff: () => { - const diff = useAtomValue(checkpointAtom); - return { data: diff === null ? null : { diff }, isPending: diff === null, error: null }; - }, -})); - -import DiffPanel from "./DiffPanel"; -import { useDiffPanelStore } from "../diffPanelStore"; - -const threadRef = scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("thread")); -const thread = { ...threadRef, worktreePath: "/repo" }; -const turns = [1, 2].map((ordinal) => ({ - runId: RunId.make(`turn-${ordinal}`), - checkpointTurnCount: ordinal, - completedAt: "2026-10-16T00:00:00Z", -})); -const paths = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]; -function filePatch(path: string) { - const count = path === "e.txt" ? 30 : 4; - const lines = Array.from({ length: count }, (_, index) => `+after ${index}`).join("\n"); - return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1,${count} @@\n-before\n${lines}\n`; -} -const source: ReviewDiffPreviewSource = { - id: "branch", - kind: "branch-range", - title: "Changes", - baseRef: "main", - headRef: "HEAD", - diff: "", - diffHash: "branch-diff", - truncated: true, - files: paths.map((path) => ({ - path, - previousPath: null, - additions: path === "e.txt" ? 30 : 4, - deletions: 1, - })), -}; -const configAtom = Atom.make(null); -const collapsedAtom = Atom.make(true); -const checkpointAtom = Atom.make(null); -const previewAtom = Atom.make( - AsyncResult.success({ - cwd: "/repo", - generatedAt: DateTime.makeUnsafe("2026-10-16T00:00:00Z"), - sources: [source, { ...source, id: "working", kind: "working-tree" }], - }), -); -function loadedPatch(path: string): ReviewDiffPreviewSource { - return { - ...source, - diff: filePatch(path), - files: source.files?.filter((file) => file.path === path) ?? [], - truncated: false, - }; -} -const patchAtoms = Atom.family((path: string) => - Atom.make>( - path === "e.txt" ? AsyncResult.initial() : AsyncResult.success(loadedPatch(path)), - ), -); -let registry: AtomRegistry.AtomRegistry; -let container: HTMLDivElement; -let root: Root; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - localStorage.setItem("t3code.diffFileTreeOpen", "true"); - useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - registry = AtomRegistry.make(); - container = document.createElement("div"); - container.style.cssText = "width: 1000px; height: 300px"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - registry.dispose(); - localStorage.clear(); - vi.unstubAllGlobals(); -}); -async function render() { - await act(async () => - root.render( - - - , - ), - ); -} -const toggle = (path: string, expanded: boolean) => - page.getByRole("button", { name: `${expanded ? "Collapse" : "Expand"} ${path}`, exact: true }); -const viewport = () => container.querySelector(".diff-render-surface")!; -async function selectTurn(path?: string, turn = turns[0]!) { - await act(async () => useDiffPanelStore.getState().selectTurn(threadRef, turn.runId, path)); -} -async function expectRevealed(path: string) { - await expect.element(toggle(path, true)).toBeInTheDocument(); - await expect - .poll(() => { - const bounds = viewport().getBoundingClientRect(); - const header = toggle(path, true).element().getBoundingClientRect(); - return viewport().scrollTop > 0 && header.top >= bounds.top && header.bottom <= bounds.bottom; - }) - .toBe(true); -} -async function scrollToStart() { - await act(async () => { - viewport().scrollTop = 0; - viewport().dispatchEvent(new Event("scroll")); - await new Promise(requestAnimationFrame); - }); -} -async function selectTreeFile(path: string) { - const tree = container.querySelector("file-tree-container")!; - const row = tree.shadowRoot!.querySelector(`[data-item-path="${path}"]`)!; - await act(async () => - row.dispatchEvent( - new MouseEvent("click", { bubbles: true, composed: true, button: 0, detail: 1 }), - ), - ); -} - -it("reveals a delayed turn selection, preserves siblings, and repeats after scope changes", async () => { - await selectTurn("e.txt"); - await render(); - await expect - .element(page.getByRole("status", { name: "Loading checkpoint diff..." })) - .toBeInTheDocument(); - await act(async () => registry.set(checkpointAtom, paths.map(filePatch).join(""))); - await expectRevealed("e.txt"); - await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "branch")); - await expect.element(toggle("a.txt", false)).toBeInTheDocument(); - await selectTurn("e.txt"); - await expectRevealed("e.txt"); - await scrollToStart(); - await toggle("a.txt", false).click(); - await selectTurn("b.txt"); - await expect.element(toggle("b.txt", true)).toBeInTheDocument(); - await toggle("b.txt", true).click(); - await selectTurn("e.txt"); - await expectRevealed("e.txt"); - await scrollToStart(); - await expect.element(toggle("a.txt", true)).toBeInTheDocument(); - await expect.element(toggle("b.txt", false)).toBeInTheDocument(); - await expect.element(toggle("c.txt", false)).toBeInTheDocument(); - await selectTurn("e.txt"); - await expectRevealed("e.txt"); - - await selectTurn(undefined, turns[1]!); - await expect.element(toggle("a.txt", false)).toBeInTheDocument(); - await expect.element(toggle("e.txt", false)).toBeInTheDocument(); -}); - -it("waits for a lazy Git patch while preserving sibling choices and expanded defaults", async () => { - registry.set(collapsedAtom, false); - await render(); - await toggle("b.txt", true).click(); - await selectTreeFile("b.txt"); - await expect.element(toggle("b.txt", true)).toBeInTheDocument(); - await toggle("b.txt", true).click(); - await scrollToStart(); - await selectTreeFile("e.txt"); - expect(viewport().scrollTop).toBe(0); - await expect.element(toggle("e.txt", true)).not.toBeInTheDocument(); - await act(async () => - registry.set(patchAtoms("e.txt"), AsyncResult.success(loadedPatch("e.txt"))), - ); - await expectRevealed("e.txt"); - await scrollToStart(); - await expect.element(toggle("a.txt", true)).toBeInTheDocument(); - await expect.element(toggle("b.txt", false)).toBeInTheDocument(); - await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged")); - await expect.element(toggle("b.txt", true)).toBeInTheDocument(); -}); diff --git a/apps/web/src/components/GitActionsControl.browser.test.tsx b/apps/web/src/components/GitActionsControl.browser.test.tsx index 5904d57f54df..915c83bb2a13 100644 --- a/apps/web/src/components/GitActionsControl.browser.test.tsx +++ b/apps/web/src/components/GitActionsControl.browser.test.tsx @@ -6,12 +6,12 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, memo } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { memo } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render } from "../../test/render"; type Branched = { readonly branch: string }; type GitActionInput = { featureBranch?: boolean; threadId?: string; action: string }; @@ -168,35 +168,23 @@ import GitActionsControl from "./GitActionsControl"; // ThreadDetailsPanel keeps this component mounted behind React.memo when threads change. const MemoGitActionsControl = memo(GitActionsControl); -let root: Root; -let container: HTMLDivElement; - -function render(thread = "thread", cwd = "/repo") { - return act(async () => - root.render( - , - ), +function control(thread = "thread", cwd = "/repo") { + return ( + ); } const button = (name: string | RegExp) => page.getByRole("button", { name, exact: typeof name === "string" }); -const commits = () => state.actions.length; - -async function openCommitDialog() { - await userEvent.click(button("Git action options")); - await userEvent.click(page.getByRole("menuitem", { name: "Commit" })); - await expect.element(page.getByRole("dialog", { name: "Commit changes" })).toBeVisible(); -} -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +// The mocked modules read `state`, so every test starts by resetting all of it. +function setup() { state.scopes = new Set([AuthSourceControlWriteScope]); state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); state.shell = { branch: "main" }; @@ -218,212 +206,19 @@ beforeEach(() => { if (state.draft && input.branch) state.draft = { ...state.draft, branch: input.branch }; }, }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); -}); - -describe("Git actions while thread details load", () => { - it("does not offer a feature branch for a server thread without task permission", async () => { - await render(); - await openCommitDialog(); - - // The primary environment's task grant must not leak into this thread's environment. - await expect.element(button("Commit on new branch")).toBeDisabled(); - expect(commits()).toBe(0); - }); - - it("checks the task grant again when the feature-branch commit runs", async () => { - state.scopes.add(AuthOrchestrationOperateScope); - await render(); - await openCommitDialog(); - state.scopes.delete(AuthOrchestrationOperateScope); - - await userEvent.click(button("Commit on new branch")); - - expect(commits()).toBe(0); - expect(state.branch).toBe("main"); - expect(state.shell?.branch).toBe("main"); - }); - - it("commits and synchronizes the server thread before details finish loading", async () => { - state.primaryScopes.clear(); - state.scopes.add(AuthOrchestrationOperateScope); - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit on new branch")); - - await vi.waitFor(() => expect(state.shell?.branch).toBe("feature")); - expect(commits()).toBe(1); - expect(state.actions[0]?.input).toMatchObject({ action: "commit", featureBranch: true }); - expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); - }); - - it("synchronizes archived thread detail after its shell disappears", async () => { - state.scopes.add(AuthOrchestrationOperateScope); - state.detail = { branch: "main" }; - await render(); - state.shell = null; - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit on new branch")); - - await vi.waitFor(() => expect(state.detail?.branch).toBe("feature")); - expect(commits()).toBe(1); - expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); - }); - - it("requires task permission before changing an archived thread's branch", async () => { - state.shell = null; - state.detail = { branch: "main" }; - await render(); - await openCommitDialog(); - - await expect.element(button("Commit on new branch")).toBeDisabled(); - expect(state.metadataRequests).toEqual([]); - }); - - it("uses the current shell branch when cached detail names the new branch", async () => { - state.scopes.add(AuthOrchestrationOperateScope); - state.detail = { branch: "feature" }; - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit on new branch")); - - await vi.waitFor(() => expect(state.shell?.branch).toBe("feature")); - expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); - }); - - it.each([ - [AuthOrchestrationOperateScope, false], - [AuthSourceControlWriteScope, true], - ] as const)( - "uses the fresh task grant after Git finishes, revoking %s", - async (revokedScope, syncsThread) => { - state.scopes.add(AuthOrchestrationOperateScope); - state.afterGitAction = () => state.scopes.delete(revokedScope); - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit on new branch")); - - await vi.waitFor(() => expect(state.branch).toBe("feature")); - expect(commits()).toBe(1); - expect(state.shell?.branch).toBe(syncsThread ? "feature" : "main"); - expect(state.metadataRequests).toHaveLength(syncsThread ? 1 : 0); - }, - ); - - it("keeps ordinary commits available while details load", async () => { - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit")); - - await vi.waitFor(() => expect(commits()).toBe(1)); - expect(state.actions[0]?.input).not.toHaveProperty("featureBranch"); - expect(state.branch).toBe("main"); - }); - - it("keeps feature-branch commits available for a local draft", async () => { - state.shell = null; - state.draft = { branch: "main", worktreePath: null, envMode: "local" }; - await render(); - await openCommitDialog(); - await userEvent.click(button("Commit on new branch")); - - await vi.waitFor(() => expect(state.draft?.branch).toBe("feature")); - expect(commits()).toBe(1); - expect(state.metadataRequests).toEqual([]); - }); -}); - -describe("Changed-file editor targets", () => { - beforeEach(() => { - state.scopes.add(AuthOrchestrationOperateScope); - state.files = [ - { path: "nested/new.txt", insertions: 0, deletions: 0 }, - { path: "nested/tracked.txt", insertions: 1, deletions: 0 }, - ]; - }); - - it("resolves repository-relative paths against the status root, not the project cwd", async () => { - state.workingTreeRoot = "/repo"; - await render("thread", "/repo/nested"); - await openCommitDialog(); - - await userEvent.click(button("Open nested/new.txt in editor")); - await userEvent.click(button("Open nested/tracked.txt in editor")); - - await vi.waitFor(() => expect(state.editorOpens).toHaveLength(2)); - expect(state.editorOpens).toEqual([ - { environmentId: "environment", input: { cwd: "/repo/nested/new.txt", editor: "cursor" } }, - { - environmentId: "environment", - input: { cwd: "/repo/nested/tracked.txt", editor: "cursor" }, - }, - ]); - }); - - it("does not guess a file location when the server reports no root", async () => { - await render("thread", "/repo/nested"); - await openCommitDialog(); - - await expect.element(button("Open nested/new.txt in editor")).toBeDisabled(); - expect(state.editorOpens).toEqual([]); - }); -}); - -describe("Default branch prompt", () => { - beforeEach(() => { - state.isDefaultRef = true; - state.scopes.add(AuthOrchestrationOperateScope); - }); - - it("moves the commit to a feature branch when asked", async () => { - await render(); - await userEvent.click(button(/^Commit & push/)); - await expect - .element(page.getByRole("dialog", { name: "Commit & push to default ref?" })) - .toBeVisible(); - expect(commits()).toBe(0); - - await userEvent.click(button("Check out feature branch & continue")); - - await vi.waitFor(() => expect(state.shell?.branch).toBe("feature")); - expect(state.actions[0]?.input).toMatchObject({ action: "commit_push", featureBranch: true }); - }); - - it("checks the task grant again when the feature branch is chosen", async () => { - await render(); - await userEvent.click(button(/^Commit & push/)); - await expect - .element(page.getByRole("dialog", { name: "Commit & push to default ref?" })) - .toBeVisible(); - state.scopes.delete(AuthOrchestrationOperateScope); - - await userEvent.click(button("Check out feature branch & continue")); - - expect(commits()).toBe(0); - expect(state.shell?.branch).toBe("main"); - }); -}); +} describe("Git action targets across thread navigation", () => { const quickAction = () => button(/^Commit, push & /); it("runs a retained PR toast in the thread that produced it", async () => { - await render("first", "/first"); + setup(); + await using screen = await render(control("first", "/first")); await userEvent.click(quickAction()); await vi.waitFor(() => expect(state.toasts.some((toast) => toast.actionProps)).toBe(true)); const toast = state.toasts.find((toast) => toast.actionProps)!; - await render("second", "/second"); + await screen.rerender(control("second", "/second")); await act(async () => toast.actionProps!.onClick()); expect(state.actions.at(-1)).toMatchObject({ @@ -431,19 +226,4 @@ describe("Git action targets across thread navigation", () => { input: { threadId: "first", action: "commit_push_pr" }, }); }); - - it("runs the memoized button in the current thread after navigation", async () => { - for (const [thread, cwd] of [ - ["first", "/first"], - ["second", "/second"], - ["first", "/first"], - ] as const) { - await render(thread, cwd); - await userEvent.click(quickAction()); - await vi.waitFor(() => - expect(state.actions.at(-1)).toMatchObject({ scope: { cwd }, input: { threadId: thread } }), - ); - } - expect(commits()).toBe(3); - }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 35f1f565aeb7..a1b7d3ea5642 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,20 +1,11 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { - buildGitActionProgressStages, buildMenuItems, - formatGitActionElapsed, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, - resolveDefaultBranchActionDialogCopy, - resolveFeatureBranchUnavailableReason, - resolveGitActionProgressPresentation, - resolveGitActionResultToastTiming, resolveLiveThreadBranchUpdate, resolveQuickAction, - resolveThreadBranchUpdate, - resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): VcsStatusResult { @@ -37,213 +28,6 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } -describe("git action progress presentation", () => { - it("keeps the phase on the first row and hook output on the second", () => { - assert.deepEqual( - resolveGitActionProgressPresentation({ - isRunning: true, - operation: "run_change_request", - currentLabel: "Running pre-commit...", - lastOutputLine: "Checking formatting and lint rules", - phaseStartedAtMs: 1_000, - hookStartedAtMs: 2_000, - }), - { - status: "Running pre-commit...", - output: "Checking formatting and lint rules", - startedAtMs: 2_000, - }, - ); - }); - - it("omits empty output and supplies a brief startup label", () => { - assert.deepEqual( - resolveGitActionProgressPresentation({ - isRunning: true, - operation: "run_change_request", - currentLabel: "Running source control action", - lastOutputLine: " ", - phaseStartedAtMs: 1_000, - hookStartedAtMs: null, - }), - { - status: "Starting source control action...", - output: null, - startedAtMs: 1_000, - }, - ); - }); - - it("presents pull progress inline without a second row", () => { - assert.deepEqual( - resolveGitActionProgressPresentation({ - isRunning: true, - operation: "pull", - currentLabel: "Pulling latest changes...", - lastOutputLine: null, - phaseStartedAtMs: 1_000, - hookStartedAtMs: null, - }), - { - status: "Pulling latest changes...", - output: null, - startedAtMs: 1_000, - }, - ); - }); - - it("does not present unrelated or completed actions", () => { - const input = { - isRunning: true, - operation: "publish_repository", - currentLabel: "Publishing repository", - lastOutputLine: null, - phaseStartedAtMs: 1_000, - hookStartedAtMs: null, - }; - - assert.isNull(resolveGitActionProgressPresentation(input)); - assert.isNull(resolveGitActionProgressPresentation({ ...input, isRunning: false })); - }); - - it("formats compact elapsed time without allowing negative values", () => { - assert.isNull(formatGitActionElapsed(null, 10_000)); - assert.equal(formatGitActionElapsed(10_000, 9_000), "0s"); - assert.equal(formatGitActionElapsed(10_000, 14_900), "4s"); - assert.equal(formatGitActionElapsed(10_000, 75_000), "1m 5s"); - }); -}); - -describe("git action result toast timing", () => { - it("keeps errors sticky and dismisses successes after visible time", () => { - assert.deepEqual(resolveGitActionResultToastTiming("error"), { - timeout: 0, - dismissAfterVisibleMs: null, - }); - assert.deepEqual(resolveGitActionResultToastTiming("success"), { - timeout: 0, - dismissAfterVisibleMs: 10_000, - }); - }); -}); - -describe("when: ref is clean and has an open PR", () => { - it("resolveQuickAction rests in the disabled up-to-date state", () => { - const quick = resolveQuickAction( - status({ - pr: { - number: 10, - title: "Open PR", - url: "https://example.com/pr/10", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Branch is up to date. No action needed.", - }); - }); - - it("buildMenuItems disables commit/push and omits the PR entry", () => { - const items = buildMenuItems( - status({ - pr: { - number: 11, - title: "Existing PR", - url: "https://example.com/pr/11", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - ]); - }); -}); - -describe("when: actions are busy", () => { - it("resolveQuickAction returns running disabled state", () => { - const quick = resolveQuickAction(status(), true); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git action in progress.", - }); - }); - - it("buildMenuItems disables all actions", () => { - const items = buildMenuItems(status(), true); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: git status is unavailable", () => { - it("resolveQuickAction returns unavailable disabled state", () => { - const quick = resolveQuickAction(null, false); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Git status is unavailable.", - }); - }); - - it("buildMenuItems returns no menu items", () => { - const items = buildMenuItems(null, false); - assert.deepEqual(items, []); - }); -}); - describe("when: ref is clean, ahead, and has an open PR", () => { it("resolveQuickAction prefers push", () => { const quick = resolveQuickAction( @@ -262,108 +46,6 @@ describe("when: ref is clean, ahead, and has an open PR", () => { ); assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" }); }); - - it("buildMenuItems enables push and omits the PR entry", () => { - const items = buildMenuItems( - status({ - aheadCount: 2, - pr: { - number: 12, - title: "Existing PR", - url: "https://example.com/pr/12", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - ]); - }); -}); - -describe("when: ref is clean, ahead, and has no open PR", () => { - it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "create_pr", - label: "Push & create PR", - }); - }); - - it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: source control provider uses merge requests", () => { - it("uses GitLab MR terminology in quick actions and menu items", () => { - const gitlabStatus = status({ - aheadCount: 2, - sourceControlProvider: { - kind: SourceControlProviderKind.make("gitlab"), - name: "GitLab", - baseUrl: "https://gitlab.com", - }, - }); - - const quick = resolveQuickAction(gitlabStatus, false); - const items = buildMenuItems(gitlabStatus, false); - - assert.deepInclude(quick, { - kind: "run_action", - action: "create_pr", - label: "Push & create MR", - }); - assert.deepInclude(items[2], { - id: "pr", - label: "Create MR", - }); - }); }); describe("when: ref is clean, up to date, and has no open PR", () => { @@ -392,213 +74,6 @@ describe("when: ref is clean, up to date, and has no open PR", () => { false, ); }); - - it("resolveQuickAction returns disabled no-action state", () => { - const quick = resolveQuickAction( - status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems disables commit, push, and create PR", () => { - const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: ref is behind upstream", () => { - it("resolveQuickAction returns pull", () => { - const quick = resolveQuickAction(status({ behindCount: 2 }), false); - assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); - }); - - it("buildMenuItems disables push and create PR", () => { - const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: ref has diverged from upstream", () => { - it("resolveQuickAction returns a disabled sync hint", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); - assert.deepEqual(quick, { - label: "Sync ref", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }); - }); -}); - -describe("when: working tree has local changes", () => { - it("resolveQuickAction returns commit, push, and create PR", () => { - const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("resolveQuickAction falls back to commit when no origin remote exists", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, hasUpstream: false }), - false, - false, - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit", - label: "Commit", - disabled: false, - }); - }); - - it("resolveQuickAction returns commit and push when open PR exists", () => { - const quick = resolveQuickAction( - status({ - hasWorkingTreeChanges: true, - pr: { - number: 16, - title: "Existing PR", - url: "https://example.com/pr/16", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Commit & push", - }); - }); - - it("buildMenuItems enables commit and disables push and PR", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("buildMenuItems enables push and create PR for ahead commits while local changes remain uncommitted", () => { - const items = buildMenuItems( - status({ - refName: "feature/test", - hasWorkingTreeChanges: true, - aheadCount: 1, - workingTree: { - files: [{ path: ".vercel/project.json", insertions: 1, deletions: 0 }], - insertions: 1, - deletions: 0, - }, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); }); describe("when: on default ref without open PR", () => { @@ -615,198 +90,9 @@ describe("when: on default ref without open PR", () => { disabled: false, }); }); - - it("resolveQuickAction returns push when ref is ahead", () => { - const quick = resolveQuickAction( - status({ refName: "main", aheadCount: 2, pr: null }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); -}); - -describe("when: working tree has local changes and ref is behind upstream", () => { - it("resolveQuickAction still prefers commit, push, and create PR", () => { - const quick = resolveQuickAction( - status({ hasWorkingTreeChanges: true, behindCount: 1 }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", - }); - }); - - it("buildMenuItems enables commit and keeps push and PR disabled", () => { - const items = buildMenuItems(status({ hasWorkingTreeChanges: true, behindCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: false, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); -}); - -describe("when: HEAD is detached and there are no local changes", () => { - it("resolveQuickAction shows detached head hint", () => { - const quick = resolveQuickAction( - status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), - false, - ); - assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); - }); - - it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); }); describe("when: ref has no upstream configured", () => { - it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { - const quick = resolveQuickAction( - status({ hasUpstream: false, pr: null, aheadCount: 0 }), - false, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction rests disabled when clean, no upstream, no local commits are ahead, and PR exists", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 0, - pr: { - number: 14, - title: "Existing PR", - url: "https://example.com/pr/14", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Commit", - disabled: true, - hint: "Branch is up to date. No action needed.", - }); - }); - - it("resolveQuickAction runs push when clean, no upstream, and local commits are ahead", () => { - const quick = resolveQuickAction( - status({ - hasUpstream: false, - aheadCount: 1, - pr: { - number: 15, - title: "Existing PR", - url: "https://example.com/pr/15", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - }), - false, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems disables push and create PR when no commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 0 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - it("resolveQuickAction runs push and create PR when no upstream and commits are ahead", () => { const quick = resolveQuickAction( status({ @@ -841,130 +127,6 @@ describe("when: ref has no upstream configured", () => { disabled: false, }); }); - - it("buildMenuItems enables create PR when no upstream and commits are ahead", () => { - const items = buildMenuItems(status({ hasUpstream: false, pr: null, aheadCount: 2 }), false); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: false, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: false, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); - - it("buildMenuItems hides push and create PR when no origin remote exists", () => { - const items = buildMenuItems( - status({ hasUpstream: false, pr: null, aheadCount: 2 }), - false, - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - ]); - }); - - it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { - const quick = resolveQuickAction( - status({ - refName: "main", - hasUpstream: false, - aheadCount: 0, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "show_hint", - label: "Push", - hint: "No local commits to push.", - disabled: true, - }); - }); - - it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { - const quick = resolveQuickAction( - status({ - refName: "main", - hasUpstream: false, - aheadCount: 1, - pr: null, - }), - false, - true, - ); - assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", - disabled: false, - }); - }); - - it("buildMenuItems still disables push and create PR when ref is behind", () => { - const items = buildMenuItems( - status({ - hasUpstream: false, - behindCount: 1, - aheadCount: 0, - pr: null, - }), - false, - ); - assert.deepEqual(items, [ - { - id: "commit", - label: "Commit", - disabled: true, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]); - }); }); describe("requiresDefaultBranchConfirmation", () => { @@ -979,189 +141,6 @@ describe("requiresDefaultBranchConfirmation", () => { }); }); -describe("resolveDefaultBranchActionDialogCopy", () => { - it("uses push-only copy when pushing without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push to default ref?", - description: - 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Push to main", - }); - }); - - it("uses push-and-pr copy when creating a PR without a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: false, - }); - - assert.deepEqual(copy, { - title: "Push & create PR from default ref?", - description: - 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Push & create PR", - }); - }); - - it("keeps commit copy when the action includes a commit", () => { - const copy = resolveDefaultBranchActionDialogCopy({ - action: "commit_push_pr", - branchName: "main", - includesCommit: true, - }); - - assert.deepEqual(copy, { - title: "Commit, push & create PR from default ref?", - description: - 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', - continueLabel: "Commit, push & create PR", - }); - }); -}); - -describe("resolveFeatureBranchUnavailableReason", () => { - it("blocks the feature branch when there is nothing to commit", () => { - assert.equal( - resolveFeatureBranchUnavailableReason({ branchName: "main", hasWorkingTreeChanges: false }), - "There are no changes to commit, so a feature branch can't be named. Create a branch yourself or continue on main.", - ); - }); - - it("allows the feature branch when there are changes to commit", () => { - assert.isNull( - resolveFeatureBranchUnavailableReason({ branchName: "main", hasWorkingTreeChanges: true }), - ); - }); -}); - -describe("buildGitActionProgressStages", () => { - it("shows only push progress for explicit push actions", () => { - const stages = buildGitActionProgressStages({ - action: "push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); - }); - - it("shows push and PR progress for create-pr actions that still need a push", () => { - const stages = buildGitActionProgressStages({ - action: "create_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - pushTarget: "origin/feature/test", - shouldPushBeforePr: true, - }); - assert.deepEqual(stages, [ - "Pushing to origin/feature/test...", - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); - - it("shows only PR progress when create-pr can skip the push", () => { - const stages = buildGitActionProgressStages({ - action: "create_pr", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: false, - shouldPushBeforePr: false, - }); - assert.deepEqual(stages, [ - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); - - it("includes commit stages for commit+push when working tree is dirty", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push", - hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, [ - "Generating commit message...", - "Committing...", - "Pushing to origin/feature/test...", - ]); - }); - - it("includes granular PR stages for commit+push+PR actions", () => { - const stages = buildGitActionProgressStages({ - action: "commit_push_pr", - hasCustomCommitMessage: true, - hasWorkingTreeChanges: true, - pushTarget: "origin/feature/test", - }); - assert.deepEqual(stages, [ - "Committing...", - "Pushing to origin/feature/test...", - "Preparing PR...", - "Generating PR content...", - "Creating pull request...", - ]); - }); -}); - -describe("resolveThreadBranchUpdate", () => { - it("returns a branch update when the action created a new branch", () => { - const update = resolveThreadBranchUpdate({ - action: "commit_push_pr", - branch: { - status: "created", - name: "feature/fix-toast-copy", - }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add ref sync", - }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, - }, - }); - - assert.deepEqual(update, { - branch: "feature/fix-toast-copy", - }); - }); - - it("returns null when the action stayed on the existing branch", () => { - const update = resolveThreadBranchUpdate({ - action: "commit_push", - branch: { - status: "skipped_not_requested", - }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: add ref sync", - }, - push: { status: "pushed", branch: "feature/fix-toast-copy" }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Pushed 89abcde to origin/feature/fix-toast-copy", - cta: { kind: "none" }, - }, - }); - - assert.equal(update, null); - }); -}); - describe("resolveLiveThreadBranchUpdate", () => { it("returns a branch update when live git status differs from stored thread metadata", () => { const update = resolveLiveThreadBranchUpdate({ @@ -1174,24 +153,6 @@ describe("resolveLiveThreadBranchUpdate", () => { }); }); - it("returns null when live git status is unavailable", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-ref", - gitStatus: null, - }); - - assert.equal(update, null); - }); - - it("returns null when the stored thread ref already matches git status", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "effect-atom", - gitStatus: status({ refName: "effect-atom" }), - }); - - assert.equal(update, null); - }); - it("returns null when git status is detached HEAD but the thread already has a ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", @@ -1209,40 +170,9 @@ describe("resolveLiveThreadBranchUpdate", () => { assert.equal(update, null); }); - - it("allows a temporary worktree ref to reconcile to a semantic branch", () => { - const update = resolveLiveThreadBranchUpdate({ - threadBranch: "t3code/a9628676", - gitStatus: status({ refName: "feature/diff-panel-toggle" }), - }); - - assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); - }); -}); - -describe("resolveThreadBranchMetadataPatch", () => { - it("does not overwrite worktree metadata while reconciling a branch", () => { - assert.deepEqual( - resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"), - { - branch: "feature/current-ref", - expectedBranch: "feature/previous-ref", - }, - ); - }); }); describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred ref names when available", () => { - const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(ref, "feature/fix-toast-copy"); - }); - - it("normalizes preferred names that already include a ref namespace", () => { - const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(ref, "feature/refine-toolbar-actions"); - }); - it("increments suffix when the preferred ref name already exists", () => { const ref = resolveAutoFeatureBranchName( ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], @@ -1255,9 +185,4 @@ describe("resolveAutoFeatureBranchName", () => { const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); assert.equal(ref, "feature/ticket-1-2"); }); - - it("falls back to feature/update when no preferred name is provided", () => { - const ref = resolveAutoFeatureBranchName(["main"]); - assert.equal(ref, "feature/update"); - }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 0a794b3f4748..1ef11206338b 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -115,46 +115,6 @@ export function formatGitActionElapsed(startedAtMs: number | null, nowMs: number return `${minutes}m ${seconds}s`; } -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - pushTarget?: string; - featureBranch?: boolean; - shouldPushBeforePr?: boolean; - terminology?: ChangeRequestTerminology; -}): string[] { - const terminology = input.terminology ?? sourceControlClients.get(undefined).changeRequest; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - const prStages = [ - `Preparing ${terminology.shortLabel}...`, - `Generating ${terminology.shortLabel} content...`, - `Creating ${terminology.singular}...`, - ]; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, ...prStages]; -} - export function buildMenuItems( gitStatus: VcsStatusResult | null, isBusy: boolean, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4257bd3c1f08..55d9f5708910 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1010,8 +1010,6 @@ export default function GitActionsControl({ const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); - // Keep the payload until the dialog finishes closing. - const [isDefaultBranchDialogOpen, setIsDefaultBranchDialogOpen] = useState(false); const sourceControlScope = { environmentId: activeEnvironmentId, cwd: gitCwd }; const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope)); const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null; @@ -1270,7 +1268,6 @@ export default function GitActionsControl({ ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); - setIsDefaultBranchDialogOpen(true); return; } onConfirmed?.(); @@ -1381,11 +1378,11 @@ export default function GitActionsControl({ } const continuePendingDefaultBranchAction = (featureBranch: boolean) => { - if (!isDefaultBranchDialogOpen || !pendingDefaultBranchAction || !canWriteSourceControl) return; + if (!pendingDefaultBranchAction || !canWriteSourceControl) return; if (featureBranch && (!canChangeThreadBranch || featureBranchUnavailableReason !== null)) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setIsDefaultBranchDialogOpen(false); + setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), @@ -1962,7 +1959,7 @@ export default function GitActionsControl({ )} diff --git a/apps/web/src/components/HostFreezeToastCoordinator.tsx b/apps/web/src/components/HostFreezeToastCoordinator.tsx new file mode 100644 index 000000000000..d04c7344b478 --- /dev/null +++ b/apps/web/src/components/HostFreezeToastCoordinator.tsx @@ -0,0 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; +import { hostFreezeNotice } from "@t3tools/client-runtime/connection"; +import { useEffect, useRef } from "react"; + +import { environmentSummaries } from "../state/presentation"; +import { environmentServerConfigsAtom } from "../state/server"; +import { toastManager } from "./ui/toast"; + +/** Tells the user why a host dropped, once per stall, when it reports one on reconnect. */ +export function HostFreezeToastCoordinator() { + const configs = useAtomValue(environmentServerConfigsAtom); + const identities = useAtomValue(environmentSummaries.identitiesAtom); + const shownRef = useRef(new Set()); + + useEffect(() => { + for (const [environmentId, config] of configs) { + const stall = config.lastEventLoopStall; + if (stall === undefined) continue; + const key = `${environmentId}:${stall.detectedAt}`; + if (shownRef.current.has(key)) continue; + shownRef.current.add(key); + const label = + identities.find((identity) => identity.environmentId === environmentId)?.label ?? + config.environment.label; + const notice = hostFreezeNotice(label, stall); + if (notice !== null) { + toastManager.add({ type: "warning", ...notice }); + } + } + }, [configs, identities]); + + return null; +} diff --git a/apps/web/src/components/HostedEmptyState.browser.test.tsx b/apps/web/src/components/HostedEmptyState.browser.test.tsx deleted file mode 100644 index 333bcf57a0fd..000000000000 --- a/apps/web/src/components/HostedEmptyState.browser.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { Discovery } from "@t3tools/client-runtime/relay"; -import { EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; -type Availability = "checking" | "online"; - -const discovery = vi.hoisted(() => ({ - state: null as Discovery.RelayEnvironmentDiscoveryState | null, - listeners: new Set<() => void>(), - register: vi.fn(), -})); - -vi.mock(import("@clerk/react"), async (original) => ({ - ...(await original()), - useAuth: (() => ({ isLoaded: true, isSignedIn: true })) as never, -})); -vi.mock(import("./clerk/useT3ConnectAuthPrompt"), () => ({ - useT3ConnectAuthPrompt: () => ({ authPrompt: null, openAuthPrompt: vi.fn() }), -})); -vi.mock(import("~/state/use-atom-command"), async (original) => { - const { relayEnvironmentDiscovery } = await import("~/state/relay"); - return { - ...(await original()), - useAtomCommand: ((command: unknown) => - command === relayEnvironmentDiscovery.refresh - ? async () => AsyncResult.success(undefined) - : discovery.register) as never, - }; -}); -vi.mock(import("~/state/environments"), async (original) => { - const { useSyncExternalStore } = await import("react"); - const subscribe = (listener: () => void) => { - discovery.listeners.add(listener); - return () => discovery.listeners.delete(listener); - }; - const read = () => discovery.state!; - return { - ...(await original()), - useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read), - }; -}); - -import { T3ConnectSteps } from "./HostedEmptyState"; - -const laptopId = EnvironmentId.make("laptop"); -const desktopId = EnvironmentId.make("desktop"); - -function linked(availability: Record): DiscoveredEnvironments { - return new Map( - [laptopId, desktopId].map((environmentId) => [ - environmentId, - { - environment: { - environmentId, - label: environmentId, - endpoint: { - httpBaseUrl: "https://relay.example.test", - wsBaseUrl: "wss://relay.example.test/ws", - providerKind: "manual", - }, - linkedAt: "2026-10-19T12:00:00.000Z", - }, - availability: availability[environmentId]!, - status: Option.none(), - error: Option.none(), - }, - ]), - ); -} - -async function publish(environments: DiscoveredEnvironments) { - discovery.state = { environments, refreshing: false, offline: false, error: Option.none() }; - await act(async () => { - for (const listener of discovery.listeners) listener(); - }); -} - -const registeredIds = () => - discovery.register.mock.calls.map(([registration]) => registration.target.environmentId); - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - discovery.listeners.clear(); - discovery.state = { - environments: new Map(), - refreshing: false, - offline: false, - error: Option.none(), - }; - discovery.register.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -// The first added computer replaces this screen with the app, so every -// registration has to start in the same pass, after all health checks finish. -it("adds every linked computer only after discovery finishes checking them", async () => { - await act(async () => root.render()); - - await publish(linked({ [laptopId]: "checking", [desktopId]: "checking" })); - await publish(linked({ [laptopId]: "online", [desktopId]: "checking" })); - expect(discovery.register).not.toHaveBeenCalled(); - - await publish(linked({ [laptopId]: "online", [desktopId]: "online" })); - expect(registeredIds().toSorted()).toEqual([desktopId, laptopId]); -}); diff --git a/apps/web/src/components/HostedEmptyState.tsx b/apps/web/src/components/HostedEmptyState.tsx index 388ca176bc68..4bf8195a5ea1 100644 --- a/apps/web/src/components/HostedEmptyState.tsx +++ b/apps/web/src/components/HostedEmptyState.tsx @@ -63,7 +63,7 @@ export function HostedEmptyState() { ); } -export function T3ConnectSteps() { +function T3ConnectSteps() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { openAuthPrompt } = useT3ConnectAuthPrompt(); // Adding the first computer unmounts this screen. Wait for all health checks diff --git a/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx b/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx deleted file mode 100644 index b3f00642ee0e..000000000000 --- a/apps/web/src/components/KeybindingsConfigWarning.browser.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { RegistryContext } from "@effect/atom-react"; -import { - AuthOrchestrationOperateScope, - EnvironmentId, - type AuthEnvironmentScope, - type AuthSessionState, -} from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -type SessionResult = AsyncResult.AsyncResult; -const state = vi.hoisted(() => ({ - sessions: new Map>(), - run: vi.fn(), - toast: vi.fn(), -})); - -vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { - const actual = await original(); - return { - ...actual, - createEnvironmentSessionAtoms: ((runtime: never) => ({ - ...actual.createEnvironmentSessionAtoms(runtime), - sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, - })) as never, - }; -}); -vi.mock(import("../state/use-atom-command"), async (original) => ({ - ...(await original()), - useAtomCommand: (() => state.run) as never, -})); -vi.mock(import("./ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: state.toast } as never }; -}); - -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { KeybindingsConfigWarning } from "./KeybindingsConfigWarning"; - -const environmentId = EnvironmentId.make("warning-environment"); -let root: Root; -let container: HTMLDivElement; -const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({ - authenticated: true, - scopes, - auth: { - policy: "remote-reachable", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["bearer-access-token"], - sessionCookieName: "t3_session", - }, -}); -const setScopes = (scopes: readonly AuthEnvironmentScope[]) => - appAtomRegistry.set(state.sessions.get(environmentId)!, AsyncResult.success(session(scopes))); -const button = () => container.querySelector("button")!; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - localStorage.setItem("t3code:last-editor", JSON.stringify("vscode")); - state.sessions.set( - environmentId, - Atom.make(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe( - Atom.keepAlive, - ), - ); - state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.toast.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - state.sessions.clear(); - vi.unstubAllGlobals(); -}); - -it("updates a visible warning on revocation and blocks a click that raced it until regrant", async () => { - await act(async () => - root.render( - - - , - ), - ); - expect(container.textContent).toContain("Invalid shortcut"); - expect(button().disabled).toBe(false); - - // The click lands before React re-renders the revoked grant. - await act(async () => { - setScopes([]); - button().click(); - }); - expect(button().disabled).toBe(true); - expect(state.run).not.toHaveBeenCalled(); - expect(state.toast).toHaveBeenCalledWith( - expect.objectContaining({ title: "Unable to open keybindings file" }), - ); - - await act(async () => setScopes([AuthOrchestrationOperateScope])); - expect(button().disabled).toBe(false); - await act(async () => button().click()); - expect(state.run).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { cwd: "/t3/keybindings.json", editor: "vscode" }, - }); -}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts deleted file mode 100644 index de5a2123cde4..000000000000 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ServerConfigStreamEvent } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - createKeybindingsUpdateToastController, - KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS, -} from "./KeybindingsUpdateToast.logic"; - -function keybindingsEvent( - overrides: Partial> = {}, -): Extract { - return { - version: 1, - type: "keybindingsUpdated", - payload: { - keybindings: [], - issues: [], - }, - ...overrides, - }; -} - -describe("keybindings update toast policy", () => { - it("coalesces repeated successful reload notifications during the cooldown", () => { - let now = 1_000; - const controller = createKeybindingsUpdateToastController({ - now: () => now, - }); - - expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); - - now += KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS - 1; - expect(controller.handle(keybindingsEvent())).toBeNull(); - - now += 1; - expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); - }); - - it("surfaces keybinding configuration issues", () => { - const controller = createKeybindingsUpdateToastController({}); - - expect( - controller.handle( - keybindingsEvent({ - payload: { - keybindings: [], - issues: [ - { - kind: "keybindings.malformed-config", - message: "Expected JSON array", - }, - ], - }, - }), - ), - ).toEqual({ - _tag: "InvalidConfiguration", - message: "Expected JSON array", - }); - }); - - it("ignores unrelated server config notifications", () => { - const controller = createKeybindingsUpdateToastController({}); - - expect( - controller.handle({ - version: 1, - type: "settingsUpdated", - payload: { settings: {} as never }, - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts index f6a47f50cfc6..ed13e6a1c800 100644 --- a/apps/web/src/components/KeybindingsUpdateToast.logic.ts +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -1,6 +1,6 @@ import type { ServerConfigStreamEvent } from "@t3tools/contracts"; -export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; +const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; export type KeybindingsUpdateToastDecision = | { readonly _tag: "Success" } diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 5c98d3d366df..aee503861c17 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -212,6 +212,7 @@ import { import { sortThreads } from "../lib/threadSort"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -442,7 +443,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); - const { isMobile } = useSidebar(); + const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx b/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx deleted file mode 100644 index 58542edf62e9..000000000000 --- a/apps/web/src/components/PermissionUpdateNotice.browser.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -type Session = { - _tag: "Success" | "Failure"; - waiting: boolean; - value: { authenticated: boolean; permissions: string[] }; -}; -const state = vi.hoisted(() => ({ - id: 0, - session: null as unknown as Session, - add: vi.fn((_notice: unknown) => "notice"), - close: vi.fn(), - navigate: vi.fn(), -})); -vi.mock(import("@effect/atom-react"), async (original) => ({ - ...(await original()), - useAtomValue: (() => state.session) as never, -})); -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useNavigate: (() => state.navigate) as never, -})); -vi.mock(import("../state/environments"), async (original) => ({ - ...(await original()), - useEnvironments: (() => ({ - environments: [{ environmentId: `env-${state.id}`, label: "Work laptop" }], - })) as never, -})); -vi.mock(import("./ui/toast"), async (original) => { - const actual = await original(); - return { - ...actual, - toastManager: { ...actual.toastManager, add: state.add, close: state.close } as never, - }; -}); -import { PermissionUpdateNotice } from "./PermissionUpdateNotice"; - -let root: Root; -let container: HTMLDivElement; -const storageKey = () => `t3code:permission-update:v1:env-${state.id}`; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.id++; - state.session = { - _tag: "Success", - waiting: false, - value: { authenticated: true, permissions: ["orchestration:operate"] }, - }; - vi.clearAllMocks(); - container = document.createElement("div"); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - localStorage.clear(); - vi.unstubAllGlobals(); -}); -const render = () => act(async () => root.render()); -type Notice = { - timeout: number; - actionProps: { onClick: () => void }; - data: { secondaryActionProps: { onClick: () => void } }; -}; -const notice = () => state.add.mock.calls[0]![0] as Notice; - -it("keeps the notice visible and persists dismissal, with a route to pairing settings", async () => { - await render(); - expect(notice().timeout).toBe(0); - expect(localStorage.getItem(storageKey())).toBeNull(); - notice().actionProps.onClick(); - expect(state.navigate).toHaveBeenCalledWith({ to: "/settings/connections" }); - expect(state.close).toHaveBeenCalledWith("notice"); - expect(localStorage.getItem(storageKey())).toBe("true"); -}); -it("does not repeat on session refresh or remount", async () => { - await render(); - await act(async () => root.unmount()); - root = createRoot(container); - state.session = { ...state.session }; - await render(); - expect(state.add).toHaveBeenCalledTimes(1); -}); -it("skips a notice dismissed on an earlier launch", async () => { - localStorage.setItem(storageKey(), "true"); - await render(); - expect(state.add).not.toHaveBeenCalled(); -}); -it("waits for a successful, settled session check", async () => { - state.session._tag = "Failure"; - await render(); - expect(state.add).not.toHaveBeenCalled(); - state.session = { ...state.session, _tag: "Success", waiting: true }; - await render(); - expect(state.add).not.toHaveBeenCalled(); - state.session = { ...state.session, waiting: false }; - await render(); - expect(state.add).toHaveBeenCalledTimes(1); -}); -it("does not warn for a granular grant", async () => { - state.session.value.permissions.push("filesystem:read"); - await render(); - expect(state.add).not.toHaveBeenCalled(); -}); - -it("persists the secondary dismissal without relying on the toast close callback", async () => { - await render(); - notice().data.secondaryActionProps.onClick(); - expect(localStorage.getItem(storageKey())).toBe("true"); - expect(state.close).toHaveBeenCalledWith("notice"); -}); diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx index f6c80158b89e..ee899bef6469 100644 --- a/apps/web/src/components/ProjectCloneToastCoordinator.tsx +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -17,7 +17,11 @@ import { useEffect, useRef } from "react"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useEnvironments } from "../state/environments"; -import { useEnvironmentProjectClones } from "../state/projectClones"; +import { + isProjectCloneToastSilenced, + silenceProjectCloneToasts, + useEnvironmentProjectClones, +} from "../state/projectClones"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; @@ -97,11 +101,20 @@ function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentI toastManager.close(current.toastId); toasts.current.delete(clone.projectId); }; + const ref = { environmentId, projectId: clone.projectId }; if (isViewingProjectDraft(clone.projectId)) { + silenceProjectCloneToasts(ref); closeToast(); continue; } if (tracked?.renderedKey === key) continue; + if ( + isProjectCloneToastSilenced(ref) && + (clone.phase === "running" || clone.phase === "done") + ) { + closeToast(); + continue; + } if (clone.phase === "running") { const options = stackedThreadToast({ diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx deleted file mode 100644 index 8fe6e0515aea..000000000000 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; - -const testState = vi.hoisted(() => ({ - faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", - lastTarget: null as unknown, -})); - -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useState: hooks.useState, - }; -}); - -vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => testState.faviconUrl, -})); -vi.mock("../state/assets", () => ({ - projectFaviconUrlAtom: (input: unknown) => { - testState.lastTarget = input; - }, -})); - -import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; - -function makeProject( - overrides: Partial & - Pick, -): ProjectFaviconProject { - return { environmentId: "environment-test" as EnvironmentId, ...overrides }; -} - -type ProjectFaviconImageProps = { - readonly cacheKey: string; - readonly src: string; - readonly className?: string | undefined; - readonly fallbackIcon: ComponentType<{ className?: string }>; -}; - -type ImageElement = ReactElement<{ - readonly src: string; - readonly onLoad?: () => void; - readonly onError?: () => void; -}>; - -type ProjectFaviconImageElement = ReactElement<{ - readonly children: [ReactElement | null, ImageElement | null, ImageElement | null]; -}>; - -function resolveImageComponent(): { - readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement; - readonly props: ProjectFaviconImageProps; -} { - hooks.beginRender(); - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }), - }) as ReactElement; - hooks.reset(); - - return { - Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: element.props, - }; -} - -function renderImage( - Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, - props: ProjectFaviconImageProps, -): ProjectFaviconImageElement { - hooks.beginRender(); - return Component(props); -} - -describe("ProjectFavicon", () => { - beforeEach(() => { - hooks.reset(); - testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; - }); - - it("shows the project monogram when no favicon exists", () => { - testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; - - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), - }) as ReactElement<{ - readonly projectName?: string; - }>; - - expect(element.props.projectName).toBe("analytics-db"); - }); - - it("uses the same monogram fallback for every project category", () => { - testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; - - const element = ProjectFavicon({ - project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), - }) as ReactElement<{ - readonly projectName?: string; - }>; - - expect(element.props.projectName).toBe("agent-runtime"); - }); - - it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { - const element = ProjectFavicon({ - project: makeProject({ - workspaceRoot: "/workspace/test", - title: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, - }), - }) as ReactElement<{ - readonly children: ReactElement<{ - readonly children: ReactElement<{ readonly name: string; readonly className: string }>; - }>; - readonly className: string; - }>; - - expect(element.props.children.props.children.props.name).toBe("alarm-clock"); - expect(element.props.className).toContain("text-violet-600"); - expect(element.props.children.props.children.props.className).toContain("text-violet-600"); - }); - - it("renders a saved emoji ahead of an uploaded favicon", () => { - const element = ProjectFavicon({ - project: makeProject({ - workspaceRoot: "/workspace/test", - title: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "emoji", emoji: "🦄" }, - }), - }) as ReactElement<{ readonly emoji: string }>; - - expect(element.props.emoji).toBe("🦄"); - }); - - it("falls back when the displayed favicon fails without discarding a valid older image early", () => { - const { Component, props } = resolveImageComponent(); - const initialLoadingImage = renderImage(Component, props).props.children[2]; - initialLoadingImage?.props.onLoad?.(); - - const refreshedProps = { - ...props, - src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg", - }; - const refreshing = renderImage(Component, refreshedProps).props.children; - expect(refreshing[1]?.props.src).toBe(props.src); - refreshing[2]?.props.onError?.(); - - const afterRefreshError = renderImage(Component, refreshedProps).props.children; - expect(afterRefreshError[1]?.props.src).toBe(props.src); - afterRefreshError[1]?.props.onError?.(); - - const afterDisplayedError = renderImage(Component, refreshedProps).props.children; - expect(afterDisplayedError[0]).not.toBeNull(); - expect(afterDisplayedError[1]).toBeNull(); - }); - - it("requests a saved favicon path when one is set", () => { - ProjectFavicon({ - project: makeProject({ - workspaceRoot: "/workspace-test", - title: "workspace-test", - faviconPath: "brand/icon.svg", - }), - }); - - expect(testState.lastTarget).toMatchObject({ - environmentId: "environment-test", - cwd: "/workspace-test", - faviconPath: "brand/icon.svg", - }); - }); -}); diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx deleted file mode 100644 index 6f6fcb4ef0c6..000000000000 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { Dispatch, ReactElement, SetStateAction } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - AuthProvidersManageScope, - type AuthEnvironmentScope, - type EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; - -import type { - LocalEnvironmentUpdateGroup, - ProviderUpdateCandidate, - ProviderUpdateRowStatus, -} from "./ProviderUpdateLaunchNotification.logic"; - -const testState = vi.hoisted(() => ({ - groups: [] as LocalEnvironmentUpdateGroup[], - permittedEnvironmentIds: new Set(), - updateProvider: vi.fn(), -})); - -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useCallback(callback: T): T { - nextIndex(); - return callback; - }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useRef(initialValue: T): { current: T } { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = { current: initialValue }; - } - return slots[index] as { current: T }; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useCallback: hooks.useCallback, - useMemo: hooks.useMemo, - useRef: hooks.useRef, - useState: hooks.useState, - }; -}); - -vi.mock("react/compiler-runtime", () => ({ - c: hooks.useMemoCache, -})); - -vi.mock("~/state/server", () => ({ - serverEnvironment: { updateProvider: Symbol("updateProvider") }, -})); - -vi.mock("~/state/session", () => ({ - readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) => - scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId), - useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => - environmentId !== null && - scope === AuthProvidersManageScope && - testState.permittedEnvironmentIds.has(environmentId), -})); - -vi.mock("~/state/use-atom-command", () => ({ - useAtomCommand: () => testState.updateProvider, -})); - -vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({ - useLocalEnvironmentUpdateGroups: () => ({ - groups: testState.groups, - isAnySettling: false, - }), -})); - -import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; - -const environmentId = "env-wsl" as EnvironmentId; -const pendingExpiryMs = 6 * 60_000; - -function provider(updateStatus?: "succeeded"): ServerProvider { - const result: ServerProvider = { - instanceId: ProviderInstanceId.make("codex-wsl"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: updateStatus ? "1.1.0" : "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-06-26T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - versionAdvisory: { - status: updateStatus ? "current" : "behind_latest", - currentVersion: updateStatus ? "1.1.0" : "1.0.0", - latestVersion: "1.1.0", - updateCommand: "npm install -g @openai/codex@latest", - canUpdate: true, - checkedAt: "2026-06-26T12:00:00.000Z", - message: updateStatus ? "Up to date." : "Update available.", - }, - }; - - return updateStatus - ? { - ...result, - updateState: { - status: updateStatus, - startedAt: "2026-06-26T12:00:00.000Z", - finishedAt: "2026-06-26T12:00:01.000Z", - message: "Provider updated.", - output: null, - }, - } - : result; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - return { promise, resolve }; -} - -type RowElement = ReactElement<{ - readonly status: ProviderUpdateRowStatus; - readonly onUpdate: () => void; -}>; - -function renderRow(): RowElement { - hooks.beginRender(); - const output = ProviderUpdateEnvironmentRows({}) as ReactElement<{ - readonly children: RowElement | RowElement[]; - }>; - const children = output.props.children; - return Array.isArray(children) ? children[0]! : children; -} - -async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); -} - -describe("ProviderUpdateEnvironmentRows", () => { - beforeEach(() => { - vi.useFakeTimers(); - hooks.reset(); - testState.permittedEnvironmentIds.clear(); - testState.permittedEnvironmentIds.add(environmentId); - testState.updateProvider.mockReset(); - const candidate = provider() as ProviderUpdateCandidate; - testState.groups = [ - { - environmentId, - label: "WSL", - isPrimary: false, - isSettling: false, - candidates: [candidate], - providers: [candidate], - }, - ]; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("keeps a successor pending when an expired request resolves late, then shows its success", async () => { - const firstRequest = - deferred>>(); - const successorRequest = - deferred>>(); - testState.updateProvider - .mockReturnValueOnce(firstRequest.promise) - .mockReturnValueOnce(successorRequest.promise); - - renderRow().props.onUpdate(); - expect(renderRow().props.status.kind).toBe("loading"); - - await vi.advanceTimersByTimeAsync(pendingExpiryMs); - expect(renderRow().props.status.kind).toBe("failed"); - - renderRow().props.onUpdate(); - expect(testState.updateProvider).toHaveBeenCalledTimes(2); - expect(renderRow().props.status.kind).toBe("loading"); - - firstRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] })); - await flushPromises(); - - expect(renderRow().props.status.kind).toBe("loading"); - - successorRequest.resolve(AsyncResult.success({ providers: [provider("succeeded")] })); - await flushPromises(); - - expect(renderRow().props.status.kind).toBe("success"); - }); -}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index feafcd1f31c5..66b8ff5318ab 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -11,26 +11,17 @@ import { AsyncResult } from "effect/reactivity"; import { buildLocalEnvironmentUpdateGroups, canOneClickUpdateProviderCandidate, - collectProviderUpdateCandidates, collectProviderUpdateOutcomeSnapshots, collectUpdatedProviderSnapshots, - deriveEnvironmentDisplayLabel, - environmentGroupsWithUpdates, - firstFailedProviderUpdateMessage, firstRejectedProviderUpdateMessage, - getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, - getProviderUpdateRejectedToastView, getProviderUpdateRunToastView, getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, isProviderSettingsUpdateCandidate, - isTerminalProviderUpdatePhase, - localEnvironmentUpdateNotificationKey, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, - shouldShowPrimaryProviderUpdateToast, type LocalEnvironmentProvidersInput, type LocalEnvironmentUpdateGroup, type LocalProviderUpdateOutcome, @@ -92,39 +83,6 @@ function updateCandidate(input: Parameters[0]): ProviderUpdateC } describe("provider update launch notification logic", () => { - it("detects enabled providers with a latest-version advisory", () => { - expect(isProviderUpdateCandidate(provider({ driver: driver("codex") }))).toBe(true); - expect(isProviderUpdateCandidate(provider({ driver: driver("codex"), enabled: false }))).toBe( - false, - ); - expect( - isProviderUpdateCandidate( - provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), - ), - ).toBe(false); - expect( - isProviderUpdateCandidate(provider({ driver: driver("codex"), latestVersion: null })), - ).toBe(false); - }); - - it("deduplicates multi-instance provider candidates by driver", () => { - expect( - collectProviderUpdateCandidates([ - provider({ - driver: driver("codex"), - instanceId: instanceId("codex_personal"), - latestVersion: "1.1.0", - }), - provider({ - driver: driver("codex"), - instanceId: instanceId("codex"), - latestVersion: "1.1.0", - }), - provider({ driver: driver("cursor"), latestVersion: "0.3.0" }), - ]), - ).toHaveLength(2); - }); - it("disables one-click updates when provider instances disagree on the update command", () => { const candidate = updateCandidate({ driver: driver("claudeAgent"), @@ -200,22 +158,6 @@ describe("provider update launch notification logic", () => { expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); }); - it("builds a notification key from provider latest versions", () => { - const codex = updateCandidate({ - driver: driver("codex"), - version: "1.0.0", - latestVersion: "1.1.0", - }); - const cursor = updateCandidate({ - driver: driver("cursor"), - version: "0.2.0", - latestVersion: "0.3.0", - }); - - expect(providerUpdateNotificationKey([codex, cursor])).toBe("codex:1.1.0|cursor:0.3.0"); - expect(providerUpdateNotificationKey([])).toBeNull(); - }); - it("keeps the same notification key while the published update version is unchanged", () => { const first = updateCandidate({ driver: driver("codex"), @@ -277,257 +219,6 @@ describe("provider update launch notification logic", () => { ).toEqual([updatedPersonal]); }); - it("describes a single one-click update", () => { - const view = getProviderUpdateInitialToastView({ - updateProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], - oneClickProviders: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], - }); - - expect(view).toMatchObject({ - phase: "initial", - type: "warning", - title: "Update Available: Codex v1.1.0", - description: "Install the update now or review provider settings.", - }); - }); - - it("describes settings-only updates without one-click support", () => { - const view = getProviderUpdateInitialToastView({ - updateProviders: [ - updateCandidate({ driver: driver("codex") }), - updateCandidate({ driver: driver("cursor") }), - ], - oneClickProviders: [], - }); - - expect(view.description).toBe("Codex and Cursor can be updated from provider settings."); - }); - - it("sends installs the server cannot update to the tool that installed them", () => { - const manual = { canUpdate: false, updateCommand: null }; - expect( - getProviderUpdateInitialToastView({ - updateProviders: [updateCandidate({ driver: driver("codex"), ...manual })], - oneClickProviders: [], - }).description, - ).toBe("Update Codex with the app or tool that installed it."); - expect( - getProviderUpdateInitialToastView({ - updateProviders: [ - updateCandidate({ driver: driver("codex"), ...manual }), - updateCandidate({ driver: driver("cursor") }), - ], - oneClickProviders: [], - }).description, - ).toBe( - "Cursor can be updated from provider settings. Update Codex with the app or tool that installed it.", - ); - const oneClick = updateCandidate({ driver: driver("claudeAgent") }); - expect( - getProviderUpdateInitialToastView({ - updateProviders: [oneClick, updateCandidate({ driver: driver("codex"), ...manual })], - oneClickProviders: [oneClick], - }).description, - ).toBe( - "Install the update now or review provider settings. Update Codex with the app or tool that installed it.", - ); - }); - - it("uses server update state for running progress", () => { - const view = getProviderUpdateProgressToastView({ - providers: [ - provider({ - driver: driver("codex"), - updateState: { - status: "running", - startedAt: checkedAt, - finishedAt: null, - message: "Updating provider.", - output: null, - }, - }), - ], - providerCount: 1, - }); - - expect(view).toMatchObject({ - phase: "running", - type: "loading", - title: "Updating provider", - }); - expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false); - }); - - it("keeps the initial prompt and terminal outcomes visible as toasts", () => { - expect( - shouldShowPrimaryProviderUpdateToast( - getProviderUpdateInitialToastView({ - updateProviders: [updateCandidate({ driver: driver("codex") })], - oneClickProviders: [updateCandidate({ driver: driver("codex") })], - }), - ), - ).toBe(true); - expect( - shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")), - ).toBe(true); - }); - - it("uses server failure state for failed progress", () => { - const view = getProviderUpdateProgressToastView({ - providers: [ - provider({ - driver: driver("codex"), - updateState: { - status: "failed", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "command failed", - output: "stderr", - }, - }), - ], - providerCount: 1, - }); - - expect(view).toMatchObject({ - phase: "failed", - type: "error", - title: "Provider update failed", - description: "command failed", - }); - }); - - it("keeps unchanged providers actionable from settings", () => { - const view = getProviderUpdateProgressToastView({ - providers: [ - provider({ - driver: driver("cursor"), - updateState: { - status: "unchanged", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "still old", - output: null, - }, - }), - ], - providerCount: 1, - }); - - expect(view).toMatchObject({ - phase: "unchanged", - type: "warning", - title: "Provider still needs an update", - description: "Cursor still appears outdated. Check provider settings for details.", - }); - }); - - it("marks progress succeeded once every attempted provider is no longer outdated", () => { - const view = getProviderUpdateProgressToastView({ - providers: [ - provider({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: { - status: "succeeded", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Provider updated.", - output: null, - }, - }), - ], - providerCount: 1, - }); - - expect(view).toMatchObject({ - phase: "succeeded", - type: "success", - title: "Provider updated", - description: "New sessions will use the updated provider.", - dismissAfterVisibleMs: 3_000, - }); - }); - - it("falls back to a rejected RPC message for transport-level failures", () => { - const results = [AsyncResult.failure(Cause.die(new Error("WebSocket closed")))]; - - expect(firstFailedProviderUpdateMessage(results)).toBe("WebSocket closed"); - expect(getProviderUpdateRejectedToastView(2, "WebSocket closed")).toMatchObject({ - phase: "failed", - title: "Provider updates failed", - description: "WebSocket closed", - }); - }); - - it("collects only attempted provider snapshots from update responses", () => { - const codex = provider({ driver: driver("codex") }); - const cursor = provider({ driver: driver("cursor") }); - const results = [AsyncResult.success({ providers: [codex, cursor] })]; - - expect( - collectUpdatedProviderSnapshots({ - results, - providerInstanceIds: new Set([cursor.instanceId]), - }), - ).toEqual([cursor]); - }); - - it("summarizes active provider updates for the sidebar pill", () => { - const view = getProviderUpdateSidebarPillView([ - provider({ - driver: driver("codex"), - updateState: { - status: "running", - startedAt: checkedAt, - finishedAt: null, - message: "Updating provider.", - output: null, - }, - }), - provider({ - driver: driver("cursor"), - updateState: { - status: "queued", - startedAt: null, - finishedAt: null, - message: "Waiting for another provider update to finish.", - output: null, - }, - }), - ]); - - expect(view).toMatchObject({ - tone: "loading", - title: "Updating 2 providers", - description: "Codex and Cursor updates are in progress.", - }); - }); - - it("uses the provider name for single active sidebar pill updates", () => { - const view = getProviderUpdateSidebarPillView([ - provider({ - driver: driver("codex"), - updateState: { - status: "running", - startedAt: checkedAt, - finishedAt: null, - message: "Updating provider.", - output: null, - }, - }), - ]); - - expect(view).toMatchObject({ - key: "loading:codex:running", - tone: "loading", - title: "Updating Codex", - description: "Codex update in progress.", - }); - }); - it("shows the active sidebar pill for an update on a non-default instance", () => { const view = getProviderUpdateSidebarPillView([ provider({ driver: driver("claudeAgent") }), @@ -590,86 +281,6 @@ describe("provider update launch notification logic", () => { }); }); - it("uses the provider name for single failed sidebar pill updates", () => { - const view = getProviderUpdateSidebarPillView( - [ - provider({ - driver: driver("claudeAgent"), - updateState: { - status: "failed", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Update command exited with code 1.", - output: null, - }, - }), - ], - { visibleAfterIso: sessionStartedAt }, - ); - - expect(view).toMatchObject({ - key: "failed:claudeAgent:2026-04-23T10:00:00.000Z:Update command exited with code 1.", - tone: "error", - title: "Claude v1.1.0 update failed", - description: "Update command exited with code 1.", - dismissible: true, - }); - }); - - it("shows a short-lived success sidebar pill after a single provider update succeeds", () => { - const view = getProviderUpdateSidebarPillView( - [ - provider({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: { - status: "succeeded", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Provider updated.", - output: null, - }, - }), - ], - { visibleAfterIso: sessionStartedAt }, - ); - - expect(view).toMatchObject({ - key: "succeeded:codex:2026-04-23T10:00:00.000Z:Provider updated.", - tone: "success", - title: "Codex updated: v1.1.0", - description: "New sessions will use the updated provider.", - dismissAfterVisibleMs: 3_000, - }); - }); - - it("keeps unchanged sidebar pill states dismissible", () => { - const view = getProviderUpdateSidebarPillView( - [ - provider({ - driver: driver("cursor"), - updateState: { - status: "unchanged", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "still old", - output: null, - }, - }), - ], - { visibleAfterIso: sessionStartedAt }, - ); - - expect(view).toMatchObject({ - key: "unchanged:cursor:2026-04-23T10:00:00.000Z:still old", - tone: "warning", - title: "Cursor still needs an update", - dismissible: true, - }); - }); - it("does not show sidebar terminal states from before the current app session", () => { expect( getProviderUpdateSidebarPillView( @@ -737,15 +348,6 @@ describe("provider update launch notification logic", () => { }); }); - it("does not show a sidebar pill for passive update availability", () => { - expect( - getProviderUpdateSidebarPillView([ - provider({ driver: driver("codex"), canUpdate: true }), - provider({ driver: driver("cursor"), canUpdate: false }), - ]), - ).toBeNull(); - }); - describe("multi-backend update outcomes", () => { const terminalState = ( status: "succeeded" | "failed" | "unchanged", @@ -798,66 +400,6 @@ describe("provider update launch notification logic", () => { ).toMatchObject({ phase: "failed" }); }); - it("surfaces a secondary backend that stayed outdated over the primary's success", () => { - const snapshots = collectProviderUpdateOutcomeSnapshots([ - fulfilledOutcome( - true, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }), - ), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("unchanged", "still outdated"), - }), - ), - ]); - - expect(snapshots[0]?.updateState?.status).toBe("unchanged"); - expect( - getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), - ).toMatchObject({ phase: "unchanged" }); - }); - - it("reports success only when every backend succeeded", () => { - const snapshots = collectProviderUpdateOutcomeSnapshots([ - fulfilledOutcome( - true, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }), - ), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }), - ), - ]); - - expect( - getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), - ).toMatchObject({ phase: "succeeded" }); - }); - - it("ignores backends that did not return the targeted instance", () => { - const primary = provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }); - const snapshots = collectProviderUpdateOutcomeSnapshots([ - fulfilledOutcome(true, primary), - fulfilledOutcome(false, null), - ]); - - expect(snapshots).toEqual([primary]); - }); - it("treats a rejected dispatch as not contributing a snapshot", () => { const primary = provider({ driver: driver("codex"), @@ -887,26 +429,6 @@ describe("provider update launch notification logic", () => { providers: input.providers, }); - it("groups each environment's outdated one-click candidates", () => { - const result = buildLocalEnvironmentUpdateGroups([ - environment({ - environmentId: "env-windows", - label: "Windows", - isPrimary: true, - providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], - }), - environment({ - environmentId: "env-wsl", - label: "WSL", - providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], - }), - ]); - - expect(result.isAnySettling).toBe(false); - expect(result.groups.map((group) => group.label)).toEqual(["Windows", "WSL"]); - expect(result.groups.every((group) => group.candidates.length === 1)).toBe(true); - }); - it("flags settling while a secondary backend is still connecting", () => { const result = buildLocalEnvironmentUpdateGroups([ environment({ @@ -923,102 +445,6 @@ describe("provider update launch notification logic", () => { ?.isSettling, ).toBe(true); }); - - it("keeps only environments that have a one-click update on offer", () => { - const { groups } = buildLocalEnvironmentUpdateGroups([ - environment({ - environmentId: "env-windows", - isPrimary: true, - providers: [provider({ driver: driver("codex") })], - }), - environment({ - environmentId: "env-wsl", - providers: [ - provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), - ], - }), - ]); - - expect(environmentGroupsWithUpdates(groups).map((group) => group.environmentId)).toEqual([ - "env-windows", - ]); - }); - - it("keys the notification by environment, driver and latest version", () => { - const noUpdates = buildLocalEnvironmentUpdateGroups([ - environment({ - environmentId: "env-windows", - isPrimary: true, - providers: [ - provider({ driver: driver("codex"), advisoryStatus: "current", latestVersion: null }), - ], - }), - ]); - expect(localEnvironmentUpdateNotificationKey(noUpdates.groups)).toBeNull(); - - const both = buildLocalEnvironmentUpdateGroups([ - environment({ - environmentId: "env-windows", - isPrimary: true, - providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], - }), - environment({ - environmentId: "env-wsl", - providers: [provider({ driver: driver("codex"), latestVersion: "1.1.0" })], - }), - ]); - const key = localEnvironmentUpdateNotificationKey(both.groups); - expect(key).toContain("env-windows=codex:1.1.0"); - expect(key).toContain("env-wsl=codex:1.1.0"); - }); - - it("labels environments by platform so they are distinguishable", () => { - expect( - deriveEnvironmentDisplayLabel({ - isWsl: false, - wslDistro: null, - platformOs: "windows", - fallbackLabel: "Jgratton24", - }), - ).toBe("Windows"); - expect( - deriveEnvironmentDisplayLabel({ - isWsl: true, - wslDistro: null, - platformOs: "linux", - fallbackLabel: "Jgratton24", - }), - ).toBe("WSL"); - expect( - deriveEnvironmentDisplayLabel({ - isWsl: true, - wslDistro: "ubuntu", - platformOs: "linux", - fallbackLabel: "Jgratton24", - }), - ).toBe("WSL · ubuntu"); - expect( - deriveEnvironmentDisplayLabel({ - isWsl: false, - wslDistro: null, - platformOs: undefined, - fallbackLabel: "My Device", - }), - ).toBe("My Device"); - }); - }); - - describe("isTerminalProviderUpdatePhase", () => { - it("treats succeeded/failed/unchanged as terminal", () => { - expect(isTerminalProviderUpdatePhase("succeeded")).toBe(true); - expect(isTerminalProviderUpdatePhase("failed")).toBe(true); - expect(isTerminalProviderUpdatePhase("unchanged")).toBe(true); - }); - - it("treats running/initial as non-terminal so they are not persisted", () => { - expect(isTerminalProviderUpdatePhase("running")).toBe(false); - expect(isTerminalProviderUpdatePhase("initial")).toBe(false); - }); }); describe("resolveEnvironmentUpdateRowStatus", () => { @@ -1030,12 +456,6 @@ describe("provider update launch notification logic", () => { candidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], providers: [], }; - const runningResult: ProviderUpdateToastView = { - phase: "running", - type: "loading", - title: "Updating providers", - description: "Running provider update command.", - }; const succeededResult: ProviderUpdateToastView = { phase: "succeeded", type: "success", @@ -1060,66 +480,6 @@ describe("provider update launch notification logic", () => { }), ).toMatchObject({ kind: "failed", text: "boom" }); }); - - it("uses a terminal result snapshot", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: undefined, - result: succeededResult, - pill: null, - isPending: false, - }), - ).toMatchObject({ kind: "success" }); - }); - - it("falls through a non-terminal result to live server state", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: undefined, - result: runningResult, - pill: successPill, - isPending: true, - }), - ).toMatchObject({ kind: "success" }); - }); - - it("shows the pending spinner before any signal arrives", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: undefined, - result: runningResult, - pill: null, - isPending: true, - }), - ).toMatchObject({ kind: "loading" }); - }); - - it("keeps a non-terminal result on loading even after pending clears", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: undefined, - result: runningResult, - pill: null, - isPending: false, - }), - ).toMatchObject({ kind: "loading" }); - }); - - it("lists the providers when idle", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: undefined, - result: undefined, - pill: null, - isPending: false, - }), - ).toMatchObject({ kind: "idle", text: "Codex" }); - }); }); }); @@ -1201,31 +561,4 @@ describe("getProviderUpdateRunToastView", () => { description: "Mac Studio · Claude: npm exited with code 1.\nLaptop · Codex: WebSocket closed", }); }); - - it("reports success when every update succeeded", () => { - const succeeded = AsyncResult.success({ - providers: [ - provider({ - driver: driver("codex"), - updateState: updateState("succeeded", "Provider updated."), - }), - ], - }); - - expect( - getProviderUpdateRunToastView([ - run("Mac Studio", "codex", succeeded), - run("Laptop", "codex", succeeded), - ]), - ).toEqual({ - type: "success", - title: "2 providers updated", - description: "New sessions will use the updated providers.", - }); - expect( - getProviderUpdateRunToastView([ - run("Server", "codex", AsyncResult.failure(Cause.interrupt())), - ]), - ).toBeNull(); - }); }); diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts index 4637d58df848..1d601205dd72 100644 --- a/apps/web/src/components/PullRequestThreadDialog.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -150,20 +150,6 @@ function prepareAction(label: "Local" | "Worktree") { return action; } -function visibleText(node: ReactNode): string { - if (Array.isArray(node)) return node.map(visibleText).join(" "); - if (typeof node === "string" || typeof node === "number") return String(node); - return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : ""; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - return { promise, resolve }; -} - describe("pull request worktree permissions", () => { beforeEach(() => { state.canOperate = false; @@ -181,23 +167,6 @@ describe("pull request worktree permissions", () => { state.prepared = []; }); - it("does not prepare a worktree or run setup with only source-control permission", async () => { - await prepareAction("Worktree")(); - - expect(state.checkouts).toEqual([]); - expect(state.setupScripts).toBe(0); - expect(state.prepared).toEqual([]); - }); - - it("prepares the worktree and thread when task permission is also granted", async () => { - state.canOperate = true; - await prepareAction("Worktree")(); - - expect(state.checkouts).toEqual(["worktree"]); - expect(state.setupScripts).toBe(1); - expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]); - }); - it("rechecks task permission before invoking a retained worktree action", async () => { state.canOperate = true; const prepare = prepareAction("Worktree"); @@ -207,69 +176,4 @@ describe("pull request worktree permissions", () => { expect(state.checkouts).toEqual([]); expect(state.setupScripts).toBe(0); }); - - it("keeps local checkout available without task permission", async () => { - await prepareAction("Local")(); - - expect(state.checkouts).toEqual(["local"]); - expect(state.setupScripts).toBe(0); - expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); - }); - - it.each(["Local", "Worktree"] as const)( - "shows the returned scope denial for a retained %s callback without starting a checkout", - async (label) => { - state.canOperate = true; - state.manager = { operation: "pull", error: null, isRunning: true }; - const prepare = prepareAction(label); - state.canWriteSourceControl = false; - - await prepare(); - - expect(visibleText(renderDialog())).toContain( - "This connection cannot change source control.", - ); - expect(state.checkouts).toEqual([]); - expect(state.setupScripts).toBe(0); - expect(state.prepared).toEqual([]); - expect(state.openChanges).toEqual([]); - expect(state.tracks).toBe(0); - expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true }); - }, - ); - - it("clears a returned error when a newly authorized retry starts, then completes normally", async () => { - const prepare = prepareAction("Local"); - state.canWriteSourceControl = false; - await prepare(); - expect(visibleText(renderDialog())).toContain("This connection cannot change source control."); - - state.canWriteSourceControl = true; - const response = deferred(); - state.pendingResponse = response.promise; - const completion = prepareAction("Local")(); - - expect(visibleText(renderDialog())).not.toContain( - "This connection cannot change source control.", - ); - expect(state.checkouts).toEqual(["local"]); - expect(state.prepared).toEqual([]); - response.resolve(undefined); - await completion; - - expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); - expect(state.openChanges).toEqual([false]); - }); - - it("keeps interrupted preparations quiet and preserves error reset behavior", async () => { - state.interrupted = true; - const before = visibleText(renderDialog()); - - await prepareAction("Local")(); - - expect(visibleText(renderDialog())).toBe(before); - expect(state.resets).toBe(1); - expect(state.prepared).toEqual([]); - expect(state.openChanges).toEqual([]); - }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index 547bde0896de..aeef58f0b782 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -2,9 +2,9 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef } from "@t3tools/contra import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; const state = vi.hoisted(() => ({ keybindings: [] as ResolvedKeybindingsConfig, @@ -86,33 +86,9 @@ const snapshot = { canGoForward: false, updatedAt: "2026-09-22T12:00:00.000Z", } as const; -let root: Root; let menuAction: ((action: string) => void) | undefined; -async function render() { - await act(() => root.render()); -} - -function press(target: Element = document.body) { - const event = new KeyboardEvent("keydown", { - key: "T", - ctrlKey: true, - shiftKey: true, - bubbles: true, - cancelable: true, - }); - target.dispatchEvent(event); - return event; -} - -function openCommandPalette() { - const palette = document.createElement("div"); - palette.dataset.commandPalette = ""; - document.body.append(palette); - return palette; -} - -beforeEach(() => { +function setup() { vi.clearAllMocks(); Object.assign(state, { keybindings: DEFAULT_RESOLVED_KEYBINDINGS, @@ -127,8 +103,11 @@ beforeEach(() => { terminalUiStateByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); +} + +async function renderShortcut() { + await using stack = new AsyncDisposableStack(); + stack.use(vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64")); window.desktopBridge = { preview: { setForwardedShortcuts: state.setShortcuts }, onMenuAction: (listener: (action: string) => void) => { @@ -138,60 +117,37 @@ beforeEach(() => { }; }, } as unknown as NonNullable; - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(() => root.unmount()); - delete window.desktopBridge; - document.body.replaceChildren(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("root reopen shortcut", () => { - it("restores a tab from settings to its owning remote thread", async () => { - useClosedViewStore - .getState() - .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); - await act(() => { - expect(press().defaultPrevented).toBe(true); - }); - expect( - selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId, - ).toBe("diff"); - expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: ref }); - expect(useClosedViewStore.getState().entries).toEqual([]); - }); - - it("leaves native shortcuts alone without history or while the palette is open", async () => { - await render(); - expect(press().defaultPrevented).toBe(false); - await act(() => { - useClosedViewStore - .getState() - .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - }); - openCommandPalette(); - expect(press().defaultPrevented).toBe(false); - expect(state.navigate).not.toHaveBeenCalled(); - expect(useClosedViewStore.getState().entries).toHaveLength(1); + stack.defer(() => { + delete window.desktopBridge; }); + stack.use(await render()); + const environment = stack.move(); + return { + /** Attaches an element to the document until the test ends. */ + append(element: HTMLElement) { + document.body.append(element); + environment.defer(() => element.remove()); + return element; + }, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; +} - it("lets the keybinding recorder capture the chord", async () => { - useClosedViewStore - .getState() - .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); - const recorder = document.createElement("input"); - recorder.dataset.keybindingCapture = ""; - document.body.append(recorder); - expect(press(recorder).defaultPrevented).toBe(false); - expect(useClosedViewStore.getState().entries).toHaveLength(1); +function press(target: Element = document.body) { + const event = new KeyboardEvent("keydown", { + key: "T", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, }); + target.dispatchEvent(event); + return event; +} +describe("root reopen shortcut", () => { it("serializes deliberate presses while a browser restore waits for RPC", async () => { + setup(); useClosedViewStore .getState() .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } }); @@ -202,7 +158,7 @@ describe("root reopen shortcut", () => { finish = resolve; }), ); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); press(); @@ -217,6 +173,7 @@ describe("root reopen shortcut", () => { }); it("does not create a second browser when navigation fails after restoring it", async () => { + setup(); const store = useClosedViewStore.getState(); const older = store.remember({ kind: "panel-tab", @@ -226,7 +183,7 @@ describe("root reopen shortcut", () => { store.remember({ kind: "browser", threadRef: ref, snapshot }); state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" })); state.navigate.mockRejectedValueOnce(new Error("navigation failed")); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); }); @@ -250,6 +207,7 @@ describe("root reopen shortcut", () => { it.each(["files", "file", "browser"] as const)( "keeps a failed %s restore retryable without blocking older history", async (kind) => { + setup(); const store = useClosedViewStore.getState(); const older = store.remember({ kind: "panel-tab", @@ -276,7 +234,7 @@ describe("root reopen shortcut", () => { ); state.workspaceAvailable = false; state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline")))); - await render(); + await using _view = await renderShortcut(); await act(() => { press(); }); @@ -305,29 +263,4 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); - - it("forwards the chord to a focused desktop browser only while history exists", async () => { - const forwardedCommands = () => - (state.setShortcuts.mock.lastCall as unknown as [Array<{ command: string }>])[0].map( - ({ command }) => command, - ); - useClosedViewStore - .getState() - .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } }); - await render(); - expect(forwardedCommands()).toContain("view.reopenClosed"); - await act(() => { - menuAction?.("view.reopenClosed"); - }); - expect(useClosedViewStore.getState().entries).toEqual([]); - expect(forwardedCommands()).not.toContain("view.reopenClosed"); - }); - - it("always forwards the layout toggles out of a focused desktop browser", async () => { - await render(); - expect(state.setShortcuts).toHaveBeenLastCalledWith([ - expect.objectContaining({ command: "sidebar.toggle" }), - expect.objectContaining({ command: "rightPanel.toggle" }), - ]); - }); }); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index bef1a174631e..fa9cbf0141c3 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -3,20 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { resolvePullRequestTabLink, - shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, - tabMuteMenuItem, } from "./RightPanelTabs"; -describe("browser profile submenu", () => { - it("reserves touch clicks for opening the choices while mouse clicks use the default", () => { - expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false); - expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true); - expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true); - }); -}); - function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -38,15 +28,6 @@ describe("surface shortcuts", () => { { shortcut: "D", available: false, label: "Diff" }, ] as const; - it("matches available surface shortcuts case-insensitively", () => { - expect(surfaceShortcutActionForKey(actions, shortcutEvent("b"))).toBe(actions[0]); - expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]); - }); - - it("does not activate unavailable surfaces", () => { - expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull(); - }); - it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -72,41 +53,6 @@ describe("surface shortcut typing contexts", () => { expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true); expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true); }); - - it("claims letters when focus sits outside any editable region", () => { - expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); - expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); - }); -}); - -describe("tabMuteMenuItem", () => { - const overlay = (audioMuted: boolean) => - ({ audioMuted, audible: false }) as Parameters[0]["overlay"]; - - it("stays disabled until the desktop tab exists", () => { - expect(tabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({ - label: "Mute tab", - disabled: true, - }); - }); - - it("stays disabled when no runtime tab id can be resolved", () => { - expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false })).toEqual({ - label: "Mute tab", - disabled: true, - }); - }); - - it("offers mute and unmute once the tab is addressable", () => { - expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true })).toEqual({ - label: "Mute tab", - disabled: false, - }); - expect(tabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true })).toEqual({ - label: "Unmute tab", - disabled: false, - }); - }); }); describe("pull request tab snapshots", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 27c1b7d87d2b..107ff51cbd5d 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -164,9 +164,7 @@ export interface PullRequestTabStatus { export type PullRequestTabStatusSeed = Pick; -export function shouldOpenDefaultBrowserProfileFromMenuClick( - pointerType: string | undefined, -): boolean { +function shouldOpenDefaultBrowserProfileFromMenuClick(pointerType: string | undefined): boolean { return pointerType !== "touch"; } @@ -235,7 +233,7 @@ function previewTabIdOf( * resolve while the preview manager's createTab is still in flight, and muting * then fails with a PreviewTabNotFoundError nothing surfaces to the user. */ -export function tabMuteMenuItem(input: { +function tabMuteMenuItem(input: { overlay: DesktopPreviewOverlay | null; canResolveRuntimeTabId: boolean; }): { label: string; disabled: boolean } { diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 25d56d69dff7..0062a69c0917 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -1,14 +1,12 @@ import { RegistryContext } from "@effect/atom-react"; -import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts"; +import { AuthSessionState, type EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; -import "~/index.css"; +import { act, render, type Screen } from "../../test/render"; type SessionResult = AsyncResult.AsyncResult; const testState = vi.hoisted(() => ({ @@ -58,23 +56,14 @@ vi.mock(import("./ui/toast"), async (original) => { return { ...actual, toastManager: { ...actual.toastManager, add: testState.toast } }; }); -import { - completeConfirmDialogClose, - readConfirmDialogState, - registerConfirmDialogHost, - resetConfirmDialogForTests, - respondToConfirmDialog, -} from "~/confirmDialog"; +import { resetConfirmDialogForTests } from "~/confirmDialog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { ServerUpdateAction, - ServerUpdateProgress, ServerUpdatesAction, type ServerUpdateTarget, } from "./ServerUpdateAction"; -import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; - const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); const legacyAuth = { @@ -96,37 +85,42 @@ const currentSession = { const success = (method: "boot-service" | "desktop-app" = "boot-service", version = "0.0.31") => AsyncResult.success({ targetVersion: version, method }); -let root: Root; -let container: HTMLDivElement; let sessionAtom: Atom.Writable; function setSession(session: SessionResult) { appAtomRegistry.set(sessionAtom, session); } -async function render(element: ReactNode) { - await act(() => - root.render( - {element}, - ), +function setup(): Disposable { + testState.updateServer.mockReset(); + testState.toast.mockReset(); + testState.clipboard.mockReset(); + testState.continueThreadsAfterServerUpdate = false; + sessionAtom = Atom.make(AsyncResult.success(currentSession)); + testState.sessionAtom = sessionAtom; + resetConfirmDialogForTests(); + return { [Symbol.dispose]: resetConfirmDialogForTests }; +} + +function renderUpdate(element: ReactNode) { + return render( + {element}, ); } -function button(name?: string) { - const buttons = [...container.querySelectorAll("button")]; +function button(screen: Screen, name?: string) { + const buttons = [...screen.container.querySelectorAll("button")]; return name === undefined ? buttons[0] : buttons.find((element) => element.textContent?.trim() === name); } -async function click(name?: string) { - await act(async () => button(name)!.click()); +async function click(screen: Screen, name?: string) { + await act(async () => button(screen, name)!.click()); } -async function renderAction( - props: Partial[0]> = {}, -): Promise { - await render( +function renderAction(props: Partial[0]> = {}) { + return renderUpdate( { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - testState.updateServer.mockReset(); - testState.toast.mockReset(); - testState.clipboard.mockReset(); - testState.continueThreadsAfterServerUpdate = false; - sessionAtom = Atom.make(AsyncResult.success(currentSession)); - testState.sessionAtom = sessionAtom; - resetConfirmDialogForTests(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - resetConfirmDialogForTests(); - vi.unstubAllGlobals(); -}); - describe("ServerUpdateAction", () => { it.each([ { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true }, @@ -175,6 +148,7 @@ describe("ServerUpdateAction", () => { ] as const)( "uses the advertised update scope $serverUpdateScope with grant $scopes", async ({ serverUpdateScope, scopes, allowed }) => { + using _state = setup(); setSession( AsyncResult.success( decodeSessionState({ @@ -189,176 +163,22 @@ describe("ServerUpdateAction", () => { ); testState.updateServer.mockResolvedValue(success()); - await renderAction(); - expect(button("Update")!.disabled).toBe(!allowed); - await click("Update"); + await using screen = await renderAction(); + expect(button(screen, "Update")!.disabled).toBe(!allowed); + await click(screen, "Update"); expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0); }, ); - it("keeps a known grant usable while the session refreshes", async () => { - setSession(AsyncResult.waiting(AsyncResult.success(currentSession))); - testState.updateServer.mockResolvedValue(success()); - - await renderAction(); - await click("Update"); - - expect(testState.updateServer).toHaveBeenCalledOnce(); - }); - it("disables the action once maintenance access is removed", async () => { - await renderAction(); + using _state = setup(); + await using screen = await renderAction(); await act(() => setSession(AsyncResult.success({ ...currentSession, scopes: [] }))); - expect(button("Update")!.disabled).toBe(true); - await click("Update"); + expect(button(screen, "Update")!.disabled).toBe(true); + await click(screen, "Update"); expect(testState.updateServer).not.toHaveBeenCalled(); }); - - it.each([ - [ - { kind: "npm-global", prefix: "/opt/node" }, - "Copy update command", - "npm install --global --prefix '/opt/node' t3@0.0.45", - "Update command copied", - "then restart t3", - ], - [ - { kind: "npx" }, - "Copy relaunch command", - "npx t3@0.0.45", - "Relaunch command copied", - "This does not update an installed t3 command.", - ], - [ - undefined, - "Copy relaunch command", - "npx t3@0.0.45", - "Relaunch command copied", - "This does not update an installed t3 command.", - ], - ] satisfies ReadonlyArray< - readonly [ServerInstallation | undefined, string, string, string, string] - >)( - "copies an honest manual command for %j without invoking remote update", - async (installation, label, command, title, guidance) => { - await renderAction({ selfUpdate: null, installation, targetVersion: "0.0.45" }); - await click(label); - expect(testState.clipboard).toHaveBeenCalledWith(command); - expect(testState.toast).toHaveBeenCalledWith( - expect.objectContaining({ - title, - description: expect.stringContaining(guidance), - }), - ); - expect(testState.updateServer).not.toHaveBeenCalled(); - }, - ); - - it("reports success only after the shared update flow reconnects", async () => { - testState.updateServer.mockResolvedValue(success()); - - await renderAction(); - await click("Update"); - - expect(testState.updateServer).toHaveBeenCalledWith({ - environmentId: "env-test", - input: { targetVersion: "0.0.31" }, - }); - expect(testState.toast).toHaveBeenCalledWith({ - type: "success", - title: "Test server updated", - description: "Reconnected on t3@0.0.31.", - }); - }); - - it("reports one result when the update action is double-clicked", async () => { - let finishUpdate: (() => void) | undefined; - testState.updateServer.mockImplementation( - () => - new Promise((resolve) => { - finishUpdate = () => resolve(success()); - }), - ); - - await renderAction(); - await act(async () => { - button("Update")!.click(); - button("Update")!.click(); - }); - - expect(testState.updateServer).toHaveBeenCalledTimes(1); - await act(async () => finishUpdate?.()); - expect(testState.toast).toHaveBeenCalledTimes(1); - }); - - it("quietly releases the action when the operation is interrupted", async () => { - testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); - - await renderAction(); - await click("Update"); - - expect(testState.toast).not.toHaveBeenCalled(); - }); - - it("keeps the manual instruction for desktop servers without remote update support", async () => { - await renderAction({ selfUpdate: "desktop-managed" }); - - expect(container.textContent).toBe( - "Update the desktop app on that machine to update this server.", - ); - expect(button()).toBeUndefined(); - }); - - it("updates remote desktop apps through the shared update flow after confirmation", async () => { - registerConfirmDialogHost(); - testState.updateServer.mockResolvedValue(success("desktop-app", "0.0.34")); - - await renderAction({ selfUpdate: "desktop-managed", desktopAppUpdate: true }); - await click("Update"); - expect(readConfirmDialogState()).toEqual( - expect.objectContaining({ - status: "confirming", - message: expect.stringContaining("Test server"), - }), - ); - await act(async () => respondToConfirmDialog(true)); - - expect(testState.updateServer).toHaveBeenCalledWith({ - environmentId: "env-test", - input: { targetVersion: "0.0.31" }, - }); - expect(testState.toast).toHaveBeenCalledWith({ - type: "success", - title: "Test server updated", - description: "Desktop app relaunched on 0.0.34.", - }); - }); - - it("leaves thread continuation off by default", async () => { - testState.updateServer.mockResolvedValue(success()); - - await renderAction({ threadContinuation: true }); - await click("Update"); - - expect(testState.updateServer).toHaveBeenCalledWith({ - environmentId: "env-test", - input: { targetVersion: "0.0.31" }, - }); - }); - - it("applies the saved thread continuation preference automatically", async () => { - testState.updateServer.mockResolvedValue(success()); - testState.continueThreadsAfterServerUpdate = true; - - await renderAction({ threadContinuation: true }); - await click("Update"); - - expect(testState.updateServer).toHaveBeenCalledWith({ - environmentId: "env-test", - input: { targetVersion: "0.0.31", continueRunningThreads: true }, - }); - }); }); describe("ServerUpdatesAction", () => { @@ -387,32 +207,15 @@ describe("ServerUpdatesAction", () => { }, ]; - const mount = (batch = targets) => render(); - - it("updates both supported machines with their own continuation preference and skips the manual machine", async () => { - testState.updateServer.mockResolvedValue(success()); - await mount(); - await click("Update all"); - - expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([ - { - environmentId: "batch-a", - input: { targetVersion: "0.0.31", continueRunningThreads: true }, - }, - { environmentId: "batch-b", input: { targetVersion: "0.0.31" } }, - ]); - expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([ - "Laptop updated", - "Office updated", - ]); - }); + const mount = (batch = targets) => renderUpdate(); it("names a failed machine while letting the other machine complete", async () => { + using _state = setup(); testState.updateServer .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed")))) .mockResolvedValueOnce(success()); - await mount(); - await click("Update all"); + await using screen = await mount(); + await click(screen, "Update all"); expect(testState.updateServer).toHaveBeenCalledTimes(2); expect(testState.toast).toHaveBeenCalledWith({ @@ -423,197 +226,6 @@ describe("ServerUpdatesAction", () => { expect(testState.toast).toHaveBeenCalledWith( expect.objectContaining({ type: "success", title: "Office updated" }), ); - expect(button("Update all")!.disabled).toBe(false); - }); - - it("starts each machine once when double-clicked and disables the action until both finish", async () => { - const completions: Array<() => void> = []; - testState.updateServer.mockImplementation( - () => - new Promise((resolve) => { - completions.push(() => resolve(success())); - }), - ); - await mount(); - await act(async () => { - button("Update all")!.click(); - button("Update all")!.click(); - }); - expect(testState.updateServer).toHaveBeenCalledTimes(2); - expect(button("Update all")!.disabled).toBe(true); - await act(async () => completions[0]!()); - expect(button("Update all")!.disabled).toBe(true); - await act(async () => completions[1]!()); - expect(button("Update all")!.disabled).toBe(false); - expect(testState.toast).toHaveBeenCalledTimes(2); - }); - - it("asks once for desktop machines and cancels the entire batch", async () => { - registerConfirmDialogHost(); - await mount( - targets.map((target, index) => - index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target, - ), - ); - await click("Update all"); - expect(readConfirmDialogState()).toEqual( - expect.objectContaining({ - status: "confirming", - message: expect.stringContaining("Laptop, Office"), - }), - ); - expect(testState.updateServer).not.toHaveBeenCalled(); - await act(async () => respondToConfirmDialog(false)); - expect(testState.updateServer).not.toHaveBeenCalled(); - expect(button("Update all")!.disabled).toBe(false); - }); -}); - -describe("ServerUpdateProgress", () => { - it("shows one calm status for the restart wait", async () => { - await render( - , - ); - expect(container.textContent).toBe("Restarting…"); - }); - - it("folds the sub-second installing handoff into the download phase", async () => { - await render( - , - ); - expect(container.textContent).toBe("Downloading…"); - }); - - it("keeps the failure visible as an alert with its error", async () => { - await render( - , - ); - expect(container.querySelector("[role=alert]")?.textContent).toBe( - "The package could not be verified.", - ); - }); -}); - -describe("server updates from the environment selector", () => { - const localId = "env-test" as EnvironmentId; - const otherId = "update-server" as EnvironmentId; - const availableEnvironments = [ - { environmentId: localId, label: "Build box", isPrimary: true }, - { environmentId: otherId, label: "Other machine", isPrimary: false }, - ].map((environment) => ({ ...environment, projectId: null, machine: "server" as const })); - const serverUpdate = { - environmentId: localId, - serverLabel: "Build box", - serverVersion: "0.0.30", - targetVersion: "0.0.31", - selfUpdate: "desktop-managed", - desktopAppUpdate: true, - } satisfies ServerUpdateTarget & { serverVersion: string }; - - it("confirms a locked chip update and honors cancellation before updating", async () => { - registerConfirmDialogHost(); - testState.updateServer.mockResolvedValue(success("desktop-app")); - await render( - , - ); - const chip = page.getByRole("button", { name: /Build box|Update/ }); - await userEvent.click(chip); - expect(readConfirmDialogState()).toMatchObject({ - status: "confirming", - message: expect.stringContaining("Build box"), - }); - expect(testState.updateServer).not.toHaveBeenCalled(); - await act(async () => { - respondToConfirmDialog(false); - completeConfirmDialogClose(); - }); - expect(testState.updateServer).not.toHaveBeenCalled(); - await userEvent.click(chip); - await act(async () => respondToConfirmDialog(true)); - expect(testState.updateServer).toHaveBeenCalledExactlyOnceWith({ - environmentId: localId, - input: { targetVersion: "0.0.31" }, - }); - }); - - it("copies from the picker without switching machines and still selects an action-like environment ID", async () => { - const update = { ...serverUpdate, selfUpdate: null, installation: { kind: "npx" } } as const; - function Picker() { - const [environmentId, setEnvironmentId] = useState(localId); - return ( - - ); - } - await render(); - const picker = page.getByRole("combobox", { name: "Run on" }); - await userEvent.click(picker); - await expect.element(page.getByRole("option", { name: "Other machine" })).toBeVisible(); - expect(testState.clipboard).not.toHaveBeenCalled(); - await userEvent.click(page.getByRole("option", { name: /Copy relaunch command/ })); - expect(testState.clipboard).toHaveBeenCalledExactlyOnceWith("npx t3@0.0.31"); - expect(testState.updateServer).not.toHaveBeenCalled(); - await expect.element(picker).toHaveTextContent("Build box"); - await userEvent.click(picker); - await expect - .element(page.getByRole("option", { name: "Build box", exact: true })) - .toHaveAttribute("aria-selected", "true"); - await userEvent.click(page.getByRole("option", { name: "Other machine" })); - await expect.element(picker).toHaveTextContent("Other machine"); - expect(testState.clipboard).toHaveBeenCalledTimes(1); - expect(testState.updateServer).not.toHaveBeenCalled(); - }); - - it("explains an unsupported desktop update in the picker without offering a runnable action", async () => { - registerConfirmDialogHost(); - await render( - , - ); - await userEvent.click(page.getByRole("combobox", { name: "Run on" })); - const instruction = page.getByRole("option", { name: /Update the desktop app/ }); - await expect.element(instruction).toBeVisible(); - await expect.element(instruction).toHaveAttribute("aria-disabled", "true"); - await userEvent.click(instruction, { force: true }); - expect(readConfirmDialogState().status).toBe("idle"); - expect(testState.updateServer).not.toHaveBeenCalled(); - expect(testState.clipboard).not.toHaveBeenCalled(); + expect(button(screen, "Update all")!.disabled).toBe(false); }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index eb279025fa4e..727dfa0c7f23 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -18,7 +18,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { CircleArrowUpIcon } from "lucide-react"; -import { type ComponentProps, type ReactNode, useRef, useState } from "react"; +import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -57,16 +57,6 @@ export interface ServerUpdateTarget { readonly continueThreadsAfterServerUpdate?: boolean; } -export type PendingServerUpdate = Omit & { - readonly serverVersion: string; -}; - -export interface ServerUpdateControl { - readonly label: string; - readonly disabled: boolean; - readonly onClick: () => void; -} - type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { readonly label?: string; readonly appearance?: "button" | "icon"; @@ -225,13 +215,8 @@ export function ServerUpdateAction({ size = "xs", className, appearance = "button", - render, -}: Omit & - UpdateButtonProps & { - readonly render?: (control: ServerUpdateControl) => ReactNode; - }) { +}: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; - const unsupportedDesktopUpdate = isDesktopAppUpdate && !desktopAppUpdate; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const continueThreadsAfterServerUpdate = useEnvironmentSettings( @@ -263,7 +248,6 @@ export function ServerUpdateAction({ const handleUpdate = async () => { if ( - unsupportedDesktopUpdate || !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || pendingUpdateEnvironmentIds.has(environmentId) ) { @@ -290,11 +274,18 @@ export function ServerUpdateAction({ }); }; + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { + return ( + + Update the desktop app on that machine to update this server. + + ); + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; - const actionLabel = unsupportedDesktopUpdate - ? "Update the desktop app on" - : manualCommand !== null + const actionLabel = + manualCommand !== null ? installation?.kind === "npm-global" ? "Copy update command" : "Copy relaunch command" @@ -303,17 +294,6 @@ export function ServerUpdateAction({ manualCommand !== null ? () => copyToClipboard(manualCommand, { command: manualCommand }) : () => void handleUpdate(); - const disabled = unsupportedDesktopUpdate || (manualCommand === null && !canUpdate); - - if (render) return render({ label: actionLabel, disabled, onClick }); - - if (unsupportedDesktopUpdate) { - return ( - - Update the desktop app on that machine to update this server. - - ); - } if (appearance === "icon") { return ( @@ -325,7 +305,7 @@ export function ServerUpdateAction({ variant="ghost-muted" className={className} aria-label={`${actionLabel} for ${serverLabel}`} - disabled={disabled} + disabled={manualCommand === null && !canUpdate} onClick={onClick} /> } @@ -342,7 +322,7 @@ export function ServerUpdateAction({ size={size} variant={variant} className={className} - disabled={disabled} + disabled={manualCommand === null && !canUpdate} onClick={onClick} > {actionLabel} diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index 261bbfdcb5e8..e1febcd68d53 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; -import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import type { SortingStrategy } from "@dnd-kit/sortable"; import { createSidebarCollisionDetection, createSidebarSortingStrategy, @@ -119,11 +119,6 @@ describe("sidebar collision detection", () => { }, ); - it("selects the nearest supported target", () => { - const detector = createSidebarCollisionDetection(() => true); - expect(detector(collisionArgs())[0]?.id).toBe("blocked"); - }); - it.each([ { sourceSection: "active", pins: 0 }, { sourceSection: "active", pins: 1 }, @@ -222,33 +217,6 @@ describe("sidebar collision detection", () => { }), ).toEqual([]); }); - - it("validates each hovered target once and always allows returning to the source", () => { - const args = collisionArgs(); - const isValid = vi.fn((id: string) => id !== "blocked"); - const detector = createSidebarCollisionDetection(isValid); - expect(detector(args)[0]?.id).toBe("source"); - expect( - detector({ - ...args, - collisionRect: { - ...args.collisionRect, - top: args.collisionRect.top + 3, - bottom: args.collisionRect.bottom + 3, - }, - })[0]?.id, - ).toBe("source"); - expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe( - "source", - ); - expect( - detector({ - ...args, - collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!, - })[0]?.id, - ).toBe(sidebarMarkerId("settled-placeholder")); - expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]); - }); }); describe("sidebar drag projection", () => { @@ -328,22 +296,6 @@ describe("sidebar drag projection", () => { thread("s1", "settled"), ]; - it.each([ - ["p1", "p2"], - ["p2", "p1"], - ])("preserves existing pinned transforms from %s to %s", (active, over) => { - const strategy = createSidebarSortingStrategy({ - items: pinned, - settledOrder: [], - settledExpanded: true, - }); - const args = layout(pinned, active, over); - for (let index = 0; index < pinned.length; index += 1) { - if (index === args.activeIndex) continue; - expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); - } - }); - it("keeps the pinned header above the gap when a lower pin moves to the top", () => { const result = preview( { items: pinned, settledOrder: [], settledExpanded: true }, @@ -356,31 +308,6 @@ describe("sidebar drag projection", () => { expect(result.get("a1")).toEqual(stationary); }); - it.each([ - ["a1", "a2"], - ["a2", "a1"], - ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => { - const items = [ - pinnedHeader, - thread("p", "pinned"), - divider, - thread("a1", "active"), - thread("a2", "active"), - settledHeader, - thread("s", "settled"), - ]; - const strategy = createSidebarSortingStrategy({ - items, - settledOrder: [], - settledExpanded: true, - }); - const args = layout(items, active, over); - for (let index = 0; index < items.length; index += 1) { - if (index === args.activeIndex) continue; - expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); - } - }); - it("preserves settled order while opening the zero-height Active target", () => { const items = [ pinnedHeader, @@ -480,57 +407,6 @@ describe("sidebar drag projection", () => { }, ); - it("stacks the labels with their gaps when the pinned section is empty", () => { - const items = [ - pinnedHeader, - divider, - thread("a1", "active"), - thread("a2", "active"), - settledHeader, - thread("s", "settled"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 }, - "a2", - "a1", - ); - expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(16); - expect(result.get("a1")?.y).toBe(32 + 83); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(32); - }); - - it("scales the label space with the measured root scale", () => { - const items = [pinnedHeader, thread("p", "pinned"), divider, thread("a1", "active")]; - const result = preview( - { items, settledOrder: [], settledExpanded: true, boundaryLabelHeight: 16 }, - "a1", - "p", - 2, - ); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(32 + 165); - }); - - it("keeps the pinned header above the first arriving pin", () => { - const items = [ - pinnedHeader, - divider, - thread("a1", "active"), - thread("a2", "active"), - settledHeader, - thread("s", "settled"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: true }, - "a2", - sidebarMarkerId("pinned-header"), - ); - expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83); - expect(result.get("a1")?.y).toBe(83); - expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); - }); - it.each([ ["p", -83, -1], ["s", 0, 82], @@ -634,24 +510,6 @@ describe("sidebar drag projection", () => { expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 }); }); - it("preserves a collapsed snoozed header while another section changes", () => { - const items = [ - pinnedHeader, - thread("p", "pinned"), - divider, - thread("a", "active"), - marker("snoozed-header"), - settledHeader, - marker("settled-placeholder"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: false }, - "a", - sidebarMarkerId("settled-placeholder"), - ); - expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 }); - }); - it("previews a time-ordered inbox drop at its time slot, above the Working shelf", () => { const items = [ pinnedHeader, @@ -672,25 +530,6 @@ describe("sidebar drag projection", () => { expect(byTime.get("w")).toEqual(stationary); }); - it("derives missing card geometry from the measured root scale", () => { - const items = [ - pinnedHeader, - divider, - marker("active-placeholder"), - settledHeader, - thread("s", "settled"), - ]; - const result = preview( - { items, settledOrder: [], settledExpanded: true }, - "s", - sidebarMarkerId("pinned-header"), - 0.75, - ); - expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); - expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5); - expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5); - }); - it("updates the projection when the target or measured geometry changes", () => { const strategy = createSidebarSortingStrategy({ items: pinned, @@ -877,6 +716,7 @@ describe("lifted card clearance", () => { } }); + // regression: #10453 the label clamp must not swallow drags that are already below it it("preserves pointer movement below the label", () => { expect(apply(511, 36, -200).y).toBe(-200); }); diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts deleted file mode 100644 index 2dbe00b66689..000000000000 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { createSidebarListMotion } from "./Sidebar.motion"; - -class TestAnimation { - progress: number | null = 0; - playState: AnimationPlayState = "running"; - effect = { getComputedTiming: () => ({ progress: this.progress }) }; - cancel = vi.fn(() => { - this.playState = "idle"; - }); - private onFinish: (() => void) | undefined; - addEventListener(_type: string, listener: () => void) { - this.onFinish = listener; - } - finish() { - this.playState = "finished"; - this.onFinish?.(); - } -} - -class TestRow { - offsetTop = 0; - offsetLeft = 4; - offsetWidth = 260; - namespaceURI = "http://www.w3.org/1999/xhtml"; - dragTranslate = 0; - style: Record = {}; - inert = false; - attributes: { name: string; value: string }[] = []; - children: TestRow[] = []; - clones: TestRow[] = []; - remove = vi.fn(); - animations: TestAnimation[] = []; - constructor( - readonly name: string, - public offsetHeight = 82, - ) {} - getBoundingClientRect() { - return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; - } - setAttribute(name: string, value: string) { - this.removeAttribute(name); - this.attributes.push({ name, value }); - } - removeAttribute(name: string) { - this.attributes = this.attributes.filter((attribute) => attribute.name !== name); - } - querySelectorAll(_selector: string): TestRow[] { - return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]); - } - cloneNode(_deep: boolean): TestRow { - const clone = new TestRow(`${this.name} clone`, this.offsetHeight); - clone.namespaceURI = this.namespaceURI; - clone.style = { ...this.style }; - clone.attributes = this.attributes.map((attribute) => ({ ...attribute })); - clone.children = this.children.map((child) => child.cloneNode(true)); - this.clones.push(clone); - return clone; - } - animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => { - const animation = new TestAnimation(); - this.animations.push(animation); - return animation; - }); -} - -function fixture(rows: TestRow[]) { - const media = { matches: false }; - const parent = { - children: rows, - ownerDocument: { defaultView: { matchMedia: () => media } }, - getBoundingClientRect: () => ({ top: 0 }), - append(node: TestRow) { - parent.children.push(node); - node.remove.mockImplementation(() => { - parent.children = parent.children.filter((child) => child !== node); - }); - }, - }; - function layout(next: TestRow[]) { - let top = 8; - for (const row of next) { - row.offsetTop = top; - top += row.offsetHeight + 1; - } - parent.children = [ - ...next, - ...parent.children.filter((row) => row.style.position === "absolute"), - ]; - } - layout(rows); - const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); - return { motion, layout, media, parent }; -} - -function expectMove(row: TestRow, offset: number) { - expect(row.animate).toHaveBeenLastCalledWith( - [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], - { duration: 150, easing: "ease-out" }, - ); -} - -beforeEach(() => vi.stubGlobal("HTMLElement", TestRow)); -afterEach(() => vi.unstubAllGlobals()); - -describe("sidebar list motion", () => { - it("moves a retained Active row into Settled with its displaced peers", () => { - const pinnedHeader = new TestRow("Pinned", 0); - const pinned = new TestRow("pin"); - const divider = new TestRow("Active", 0); - const a = new TestRow("a"); - const b = new TestRow("b"); - const settledHeader = new TestRow("Settled", 32); - const settled = new TestRow("settled", 36); - const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled]; - const { motion, layout } = fixture(rows); - motion.update(true); - expect(rows.every((row) => row.animations.length === 0)).toBe(true); - - a.offsetHeight = 36; - layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]); - motion.update(true); - expectMove(a, -153); - expectMove(b, 83); - expectMove(settledHeader, 83); - expectMove(settled, 83); - expect(pinned.animate).not.toHaveBeenCalled(); - expect(divider.animate).not.toHaveBeenCalled(); - }); - - it("refreshes the drop baseline without replay and animates the next ordinary move", () => { - const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; - const { motion, layout } = fixture([a, b, c]); - motion.update(true); - motion.suspend(); - a.dragTranslate = 300; - b.dragTranslate = -83; - motion.update(false); - motion.suspend(); - layout([b, a, c]); - a.dragTranslate = b.dragTranslate = 0; - motion.update(true); - expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true); - - layout([c, b, a]); - motion.update(true); - expectMove(c, 166); - expectMove(b, -83); - expectMove(a, -83); - }); - - it("glides every row from its released position into its committed slot", () => { - const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; - const { motion, layout } = fixture([a, b, c]); - motion.update(true); - motion.suspend(); - a.dragTranslate = 130; - b.dragTranslate = -83; - c.dragTranslate = 16; - motion.update(false); - motion.release(); - layout([b, a, c]); - a.dragTranslate = b.dragTranslate = c.dragTranslate = 0; - motion.update(true); - expectMove(a, 47); - expect(b.animations).toHaveLength(0); - expectMove(c, 16); - }); - - it("does not glide on release when motion is reduced", () => { - const [a, b] = [new TestRow("a"), new TestRow("b")]; - const { motion, layout, media } = fixture([a, b]); - motion.update(true); - media.matches = true; - a.dragTranslate = 100; - motion.release(); - layout([b, a]); - a.dragTranslate = 0; - motion.update(true); - expect(a.animations).toHaveLength(0); - expect(b.animations).toHaveLength(0); - }); - - it("does not carry a canceled drag's transformed position into the next move", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const { motion, layout } = fixture([a, b]); - motion.update(true); - motion.suspend(); - a.dragTranslate = 500; - b.dragTranslate = -83; - motion.update(false); - motion.suspend(); - a.dragTranslate = b.dragTranslate = 0; - motion.update(true); - expect(a.animate).not.toHaveBeenCalled(); - expect(b.animate).not.toHaveBeenCalled(); - - layout([b, a]); - motion.update(true); - expectMove(a, -83); - expectMove(b, 83); - }); - - it("retargets rapid changes from the current visual position", () => { - const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)]; - const { motion, layout } = fixture([a, b, c]); - motion.update(true); - layout([b, c, a]); - motion.update(true); - expectMove(a, -200); - const first = a.animations[0]!; - first.progress = 0.25; - - layout([b, a, c]); - motion.update(true); - expect(first.cancel).toHaveBeenCalledOnce(); - expectMove(a, -50); - first.finish(); - motion.suspend(); - expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); - }); - - it("keeps an uninterrupted movement when the layout position does not change", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const { motion, layout } = fixture([a, b]); - motion.update(true); - layout([b, a]); - motion.update(true); - a.animations[0]!.progress = 0.5; - motion.update(true); - expect(a.animate).toHaveBeenCalledOnce(); - expect(a.animations[0]!.cancel).not.toHaveBeenCalled(); - }); - - it("cancels owned motion on suspension and never animates a disposed list", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const { motion, layout } = fixture([a, b]); - motion.update(true); - layout([b, a]); - motion.update(true); - motion.suspend(); - expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(b.animations[0]!.cancel).toHaveBeenCalledOnce(); - motion.update(false); - layout([a, b]); - motion.update(true); - motion.dispose(); - expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); - layout([b, a]); - motion.update(true); - expect(a.animate).toHaveBeenCalledTimes(2); - }); - - it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const fresh = new TestRow("new"); - a.setAttribute("data-thread-item", "a"); - a.children = [new TestRow("button")]; - a.children[0]!.setAttribute("id", "thread-control"); - a.children[0]!.setAttribute("data-testid", "thread-control"); - a.children[0]!.setAttribute("data-state", "open"); - const icon = new TestRow("provider icon"); - icon.namespaceURI = "http://www.w3.org/2000/svg"; - icon.setAttribute("id", "provider-mask"); - icon.setAttribute("mask", "url(#provider-mask)"); - a.children.push(icon); - const { motion, layout, parent } = fixture([a, b]); - motion.update(true); - layout([b, a]); - motion.update(true); - a.animations[0]!.progress = 0.5; - layout([b, fresh]); - motion.update(true); - expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(fresh.animate).toHaveBeenLastCalledWith( - [ - { opacity: 0, transform: "translateY(83px)" }, - { opacity: 1, transform: "translateY(0px)" }, - ], - { - duration: 150, - easing: "ease-out", - }, - ); - const clone = a.clones[0]!; - expect(clone.style).toMatchObject({ - position: "absolute", - top: "49.5px", - left: "4px", - width: "260px", - height: "82px", - transform: "none", - pointerEvents: "none", - }); - expect(clone.inert).toBe(true); - expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); - expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); - expect(clone.children[1]!.attributes).toEqual(icon.attributes); - expect(clone.animate).toHaveBeenCalledWith( - [ - { opacity: 1, transform: "translateY(0px)" }, - { opacity: 0, transform: "translateY(-83px)" }, - ], - { - duration: 150, - easing: "ease-out", - }, - ); - expect(parent.children.includes(clone)).toBe(true); - motion.update(true); - expect(clone.animations).toHaveLength(1); - expect(clone.clones).toHaveLength(0); - clone.animations[0]!.finish(); - expect(parent.children.includes(clone)).toBe(false); - }); - - it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => { - const a = new TestRow("a", 40); - const header = new TestRow("header", 32); - const x = new TestRow("x", 36); - const y = new TestRow("y", 36); - const z = new TestRow("z", 36); - const { motion, layout } = fixture([a, header, x]); - motion.update(true); - layout([a, header, x, y, z]); - for (const row of [header, x, y, z]) row.offsetTop -= 72; - motion.update(true); - expectMove(header, 72); - expectMove(x, 72); - expect(a.animate).not.toHaveBeenCalled(); - expect(y.animate).toHaveBeenLastCalledWith( - [ - { opacity: 0, transform: "translateY(72px)" }, - { opacity: 1, transform: "translateY(0px)" }, - ], - { duration: 150, easing: "ease-out" }, - ); - expect(z.animate).toHaveBeenLastCalledWith( - [ - { opacity: 0, transform: "translateY(72px)" }, - { opacity: 1, transform: "translateY(0px)" }, - ], - { duration: 150, easing: "ease-out" }, - ); - }); - - it("rides new rows on a retained header's unfinished travel", () => { - const header = new TestRow("header", 32); - const above = new TestRow("above", 39); - const incoming = new TestRow("incoming", 36); - const { motion, layout } = fixture([header]); - motion.update(true); - layout([above, header]); - motion.update(true); - header.animations[0]!.progress = 0.25; - layout([above, header, incoming]); - motion.update(true); - expect(incoming.animate).toHaveBeenLastCalledWith( - [ - { opacity: 0, transform: "translateY(-30px)" }, - { opacity: 1, transform: "translateY(0px)" }, - ], - { duration: 150, easing: "ease-out" }, - ); - }); - - it("cancels in-flight row travel when a bulk update skips animation", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const { motion, layout } = fixture([a, b]); - motion.update(true); - layout([b, a]); - motion.update(true); - const first = a.animations[0]!; - first.progress = 0.4; - const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); - layout(incoming); - motion.update(true); - expect(first.cancel).toHaveBeenCalledOnce(); - expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true); - }); - - it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => { - const a = new TestRow("a", 40); - const header = new TestRow("header", 32); - const x = new TestRow("x", 36); - const y = new TestRow("y", 36); - const { motion, layout } = fixture([a, header, x]); - motion.update(true); - layout([a, header, x, y]); - for (const row of [header, x, y]) row.offsetTop -= 36; - motion.update(true); - const entry = y.animations[0]!; - header.animations[0]!.progress = 0.25; - x.animations[0]!.progress = 0.25; - entry.progress = 0.25; - layout([a, header, x]); - motion.update(true); - expect(entry.cancel).toHaveBeenCalledOnce(); - const clone = y.clones[0]!; - expect(clone.style.top).toBe("110px"); - expect(clone.animate).toHaveBeenCalledWith( - [ - { opacity: 0.25, transform: "translateY(0px)" }, - { opacity: 0, transform: "translateY(9px)" }, - ], - { duration: 150, easing: "ease-out" }, - ); - expectMove(header, -9); - expectMove(x, -9); - }); - - it("clears exit clones on pickup and does not fade the release commit", () => { - const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; - const { motion, layout, parent } = fixture([a, b]); - motion.update(false); - layout([b]); - motion.update(true); - const clone = a.clones[0]!; - motion.suspend(); - expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(parent.children.includes(clone)).toBe(false); - motion.update(false); - motion.suspend(); - layout([c]); - motion.update(true); - expect(b.clones).toHaveLength(0); - expect(c.animations).toHaveLength(0); - layout([c, a]); - motion.update(true); - expect(a.animate).toHaveBeenCalledWith( - [ - { opacity: 0, transform: "translateY(0px)" }, - { opacity: 1, transform: "translateY(0px)" }, - ], - { - duration: 150, - easing: "ease-out", - }, - ); - motion.dispose(); - expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); - }); - - it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => { - const a = new TestRow("a"); - const marker = new TestRow("boundary", 0); - const { motion, layout, parent } = fixture([marker]); - motion.update(false); - layout([marker, a]); - motion.update(true); - a.animations[0]!.progress = 0.4; - layout([]); - motion.update(true); - expect(marker.clones).toHaveLength(0); - const clone = a.clones[0]!; - expect(clone.animate).toHaveBeenCalledWith( - [ - { opacity: 0.4, transform: "translateY(0px)" }, - { opacity: 0, transform: "translateY(40px)" }, - ], - { - duration: 150, - easing: "ease-out", - }, - ); - motion.update(false); - expect(parent.children).toEqual([]); - expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); - }); - - it("retargets an in-flight entry when a later layout shift moves the row", () => { - const a = new TestRow("a", 40); - const header = new TestRow("header", 32); - const x = new TestRow("x", 36); - const y = new TestRow("y", 36); - const { motion, layout } = fixture([a, header, x]); - motion.update(true); - layout([a, header, x, y]); - for (const row of [header, x, y]) row.offsetTop -= 36; - motion.update(true); - const entry = y.animations[0]!; - entry.progress = 0.25; - for (const row of [header, x, y]) row.offsetTop += 40; - motion.update(true); - expect(entry.cancel).not.toHaveBeenCalled(); - expectMove(y, -13); - }); - - it("skips fades when a large list change would clone too many rows", () => { - const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); - const { motion, layout, parent } = fixture(rows); - motion.update(true); - layout([]); - motion.update(true); - expect(rows.every((row) => row.clones.length === 0)).toBe(true); - expect(parent.children).toHaveLength(0); - const entering = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); - layout(entering); - motion.update(true); - expect(entering.every((row) => row.animate.mock.calls.length === 0)).toBe(true); - }); - - it("still slides rows when one removal displaces a large list", () => { - const rows = Array.from({ length: 60 }, (_, index) => new TestRow(`row-${index}`)); - const { motion, layout } = fixture(rows); - motion.update(true); - const [removed, ...rest] = rows; - layout(rest); - motion.update(true); - expect(removed!.clones).toHaveLength(1); - expect(rest.every((row) => row.animations.length === 1)).toBe(true); - }); - - it("respects reduced motion while keeping the next baseline fresh", () => { - const a = new TestRow("a"); - const b = new TestRow("b"); - const { motion, layout, media } = fixture([a, b]); - motion.update(true); - media.matches = true; - layout([b, a]); - motion.update(true); - expect(a.animate).not.toHaveBeenCalled(); - media.matches = false; - layout([a, b]); - motion.update(true); - expectMove(a, 83); - expectMove(b, -83); - }); -}); diff --git a/apps/web/src/components/Sidebar.pointer.browser.test.tsx b/apps/web/src/components/Sidebar.pointer.browser.test.tsx index 45ec1d60d177..2cbaab9e14e5 100644 --- a/apps/web/src/components/Sidebar.pointer.browser.test.tsx +++ b/apps/web/src/components/Sidebar.pointer.browser.test.tsx @@ -1,11 +1,9 @@ import type { SensorProps } from "@dnd-kit/core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { act, StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { fakeTimers } from "../../test/render"; +import { SidebarPointerSensor } from "./Sidebar.pointer"; const sensors: SidebarPointerSensor[] = []; -let hidden = false; function pointer(type: string, values: PointerEventInit = {}) { return new PointerEvent(type, { @@ -42,107 +40,21 @@ function gesture( return { sensor, onFinish, ...callbacks }; } -beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - hidden = false; - vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden); -}); -afterEach(() => { - for (const sensor of sensors.splice(0)) sensor.cancel(); - // Cancelled sensors keep suppressing clicks on the shared document until the next press. - document.dispatchEvent(pointer("pointerdown")); - vi.runAllTimers(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("sidebar pointer lifecycle", () => { - it("keeps a click idle and starts only after the drag threshold", () => { - const click = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 16 })); - expect(click.onStart).not.toHaveBeenCalled(); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - expect(click.onAbort).toHaveBeenCalledOnce(); - expect(click.onFinish).toHaveBeenCalledOnce(); - - const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 17 })); - expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - expect(drag.onEnd).toHaveBeenCalledOnce(); - expect(drag.onAbort).not.toHaveBeenCalled(); - expect(drag.onFinish).toHaveBeenCalledOnce(); - }); - - it("moves to the pointer on the move that starts the drag", () => { - const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 90 })); - expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); - expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 }); - }); - - const interruptions = { - blur: () => window.dispatchEvent(new Event("blur")), - hidden: () => { - hidden = true; - document.dispatchEvent(new Event("visibilitychange")); - }, - pagehide: () => window.dispatchEvent(new Event("pagehide")), - resize: () => window.dispatchEvent(new Event("resize")), - escape: () => document.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })), - pointercancel: () => document.dispatchEvent(pointer("pointercancel")), - "missed release": () => - document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })), - }; - for (const [name, interrupt] of Object.entries(interruptions)) { - it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => { - const drag = gesture(); - if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - interrupt(); - document.dispatchEvent(pointer("pointermove", { clientY: 100 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - drag.sensor.cancel(); - expect(drag.onCancel).toHaveBeenCalledOnce(); - expect(drag.onEnd).not.toHaveBeenCalled(); - expect(drag.onFinish).toHaveBeenCalledOnce(); - expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); - expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1); - - hidden = false; - const next = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - expect(next.onStart).toHaveBeenCalledOnce(); - expect(next.onEnd).toHaveBeenCalledOnce(); - }); - } - - it("hands claimed moves and drops to the context handlers and cancels the sort", () => { - const onMove = vi.fn((point: { x: number; y: number }) => point.x > 50); - const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); - const drag = gesture({ onMove, onDrop }); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); - expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 }); - expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 }); - document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); - expect(drag.onMove).toHaveBeenCalledTimes(2); - document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); - expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 }); - expect(drag.onCancel).toHaveBeenCalledOnce(); - expect(drag.onEnd).not.toHaveBeenCalled(); - expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); - - const inside = gesture({ onMove, onDrop }); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 10, clientY: 20 })); - expect(inside.onEnd).toHaveBeenCalledOnce(); - expect(inside.onCancel).not.toHaveBeenCalled(); +function setup() { + const stack = new DisposableStack(); + stack.use(fakeTimers()); + stack.defer(() => { + for (const sensor of sensors.splice(0)) sensor.cancel(); + // Cancelled sensors keep suppressing clicks on the shared document until the next press. + document.dispatchEvent(pointer("pointerdown")); + vi.runAllTimers(); }); + return stack; +} +describe("sidebar pointer lifecycle", () => { it("drops context when a zero-button move precedes pointerup", () => { + using _environment = setup(); const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); @@ -159,7 +71,9 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); }); + // regression: #17373 a zero-button release outside a drop target must cancel, not reorder it("cancels a missed sidebar release without committing a reorder", () => { + using _environment = setup(); const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); @@ -171,115 +85,4 @@ describe("sidebar pointer lifecycle", () => { expect(drag.onEnd).not.toHaveBeenCalled(); expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); }); - - it("does not drop context when the button is released before activation", () => { - const onDrop = vi.fn(() => true); - const drag = gesture({ onDrop }); - document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 90, clientY: 30 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); - - expect(onDrop).not.toHaveBeenCalled(); - expect(drag.onStart).not.toHaveBeenCalled(); - expect(drag.onAbort).toHaveBeenCalledOnce(); - expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(false); - }); - - it("resumes reordering when a context drag returns to the sidebar", () => { - const drag = gesture({ - onMove: (point) => point.x > 50, - onDrop: (point) => point.x > 50, - }); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 })); - document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 })); - document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 })); - expect(drag.onMove).toHaveBeenCalledTimes(2); - expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 }); - - document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 })); - expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 }); - document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 30, clientY: 60 })); - expect(drag.onEnd).toHaveBeenCalledOnce(); - expect(drag.onCancel).not.toHaveBeenCalled(); - }); - - it.each([false, true])("suppresses a cancelled release click, started=%s", (started) => { - const drag = gesture(); - if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - drag.sensor.cancel(); - vi.advanceTimersByTime(1000); - const releaseClick = new Event("click"); - const releasePropagation = vi.spyOn(releaseClick, "stopPropagation"); - document.dispatchEvent(releaseClick); - expect(releasePropagation).toHaveBeenCalledOnce(); - document.dispatchEvent(pointer("pointerdown")); - const nextClick = new Event("click"); - const nextPropagation = vi.spyOn(nextClick, "stopPropagation"); - document.dispatchEvent(nextClick); - expect(nextPropagation).not.toHaveBeenCalled(); - }); - - it.each([false, true])("allows a fresh click after an outside release, started=%s", (started) => { - const drag = gesture(); - if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - drag.sensor.cancel(); - document.dispatchEvent(pointer("pointerdown")); - const click = new Event("click"); - const propagation = vi.spyOn(click, "stopPropagation"); - document.dispatchEvent(click); - expect(propagation).not.toHaveBeenCalled(); - }); - - it("does not move after cancellation during activation", () => { - const drag = gesture(); - drag.onStart.mockImplementation(() => drag.sensor.cancel()); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - expect(drag.onCancel).toHaveBeenCalledOnce(); - expect(drag.onMove).not.toHaveBeenCalled(); - expect(drag.onFinish).toHaveBeenCalledOnce(); - }); - - it("ignores events from other pointers", () => { - const drag = gesture(); - document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 })); - document.dispatchEvent(pointer("pointercancel", { pointerId: 2 })); - document.dispatchEvent(pointer("pointerup", { pointerId: 2 })); - expect(drag.onStart).not.toHaveBeenCalled(); - expect(drag.onFinish).not.toHaveBeenCalled(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - expect(drag.onStart).toHaveBeenCalledOnce(); - }); - - it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => { - const root = createRoot(document.createElement("div")); - let drag: ReturnType | undefined; - act(() => - root.render( - - drag?.sensor.cancel()} /> - , - ), - ); - drag = gesture(); - if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - act(() => root.unmount()); - document.dispatchEvent(pointer("pointermove", { clientY: 100 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); - expect(drag.onCancel).toHaveBeenCalledOnce(); - expect(drag.onEnd).not.toHaveBeenCalled(); - expect(drag.onFinish).toHaveBeenCalledOnce(); - }); - - it("detaches a cancelled sensor before a replacement gesture starts", () => { - const previous = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - previous.sensor.cancel(); - const next = gesture(); - document.dispatchEvent(pointer("pointermove", { clientY: 20 })); - document.dispatchEvent(pointer("pointerup", { buttons: 0 })); - expect(previous.onCancel).toHaveBeenCalledOnce(); - expect(previous.onEnd).not.toHaveBeenCalled(); - expect(next.onEnd).toHaveBeenCalledOnce(); - }); }); diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts deleted file mode 100644 index e51c03759e74..000000000000 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveSnoozePresets, snoozeWakeDescription } from "./Sidebar.snooze"; - -function localDate(year: number, month: number, day: number, hour: number, minute = 0): Date { - return new Date(year, month - 1, day, hour, minute, 0, 0); -} - -describe("resolveSnoozePresets", () => { - it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); - expect(presets.map((preset) => preset.id)).toEqual([ - "hour", - "three-hours", - "evening", - "tomorrow", - "next-week", - ]); - const threeHours = presets.find((preset) => preset.id === "three-hours"); - expect(new Date(threeHours!.snoozedUntil).getHours()).toBe(13); - const evening = presets.find((preset) => preset.id === "evening"); - expect(new Date(evening!.snoozedUntil).getHours()).toBe(18); - const tomorrow = presets.find((preset) => preset.id === "tomorrow"); - const tomorrowDate = new Date(tomorrow!.snoozedUntil); - expect(tomorrowDate.getDate()).toBe(9); - expect(tomorrowDate.getHours()).toBe(9); - const nextWeek = presets.find((preset) => preset.id === "next-week"); - const nextWeekDate = new Date(nextWeek!.snoozedUntil); - expect(nextWeekDate.getDay()).toBe(1); - expect(nextWeekDate.getDate()).toBe(13); - }); - - it("whenLabel complements the label instead of repeating it", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); - for (const preset of presets) { - expect(preset.whenLabel.toLowerCase()).not.toContain("tomorrow"); - } - const tomorrow = presets.find((preset) => preset.id === "tomorrow"); - expect(tomorrow!.whenLabel).toMatch(/9/); - const nextWeek = presets.find((preset) => preset.id === "next-week"); - expect(nextWeek!.whenLabel).toMatch(/Mon/); - }); - - it("drops the evening preset once evening is near or past", () => { - expect( - resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id), - ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); - expect( - resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id), - ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); - }); - - it("puts next week a full week out when today is Monday", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale"); - const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); - expect(nextWeek.getDay()).toBe(1); - expect(nextWeek.getDate()).toBe(13); - }); - it("formats preset times with the selected clock preference", () => { - const twelveHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "12-hour"); - const twentyFourHour = resolveSnoozePresets(localDate(2026, 4, 8, 10), "24-hour"); - - expect(twelveHour.find((preset) => preset.id === "evening")!.whenLabel).toMatch(/PM/i); - expect(twentyFourHour.find((preset) => preset.id === "evening")!.whenLabel).toBe("18:00"); - }); -}); - -describe("snoozeWakeDescription", () => { - const now = localDate(2026, 4, 8, 10); - - it("uses bare time today, 'tomorrow' next day, weekday within the week", () => { - expect( - snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "locale"), - ).not.toContain("tomorrow"); - expect(snoozeWakeDescription(localDate(2026, 4, 9, 9).toISOString(), now, "locale")).toContain( - "tomorrow", - ); - expect(snoozeWakeDescription(localDate(2026, 4, 13, 9).toISOString(), now, "locale")).toMatch( - /Mon/, - ); - }); - - it("formats wake descriptions with the selected clock preference", () => { - expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "12-hour")).toMatch( - /PM/i, - ); - expect(snoozeWakeDescription(localDate(2026, 4, 8, 18).toISOString(), now, "24-hour")).toBe( - "18:00", - ); - }); -}); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index f7507de6d53a..38595f3a1654 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -9,8 +9,6 @@ import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; export { snoozeWakeLabel, type SnoozePreset }; -const DAY_MS = 24 * 60 * 60 * 1_000; - function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { return formatShortTimestamp(date.toISOString(), timestampFormat); } @@ -32,22 +30,3 @@ export function resolveSnoozePresets( }; }); } - -export function snoozeWakeDescription( - snoozedUntil: string, - now: Date, - timestampFormat: TimestampFormat, -): string { - const wake = parseTimestampDate(snoozedUntil); - if (wake === null) return ""; - const time = timeOfDayLabel(wake, timestampFormat); - const startOfToday = new Date(now); - startOfToday.setHours(0, 0, 0, 0); - const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); - if (dayDelta === 0) return time; - if (dayDelta === 1) return `tomorrow ${time}`; - const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); - if (dayDelta < 7) return `${weekday} ${time}`; - const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); - return `${date}, ${time}`; -} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3beabc9c7c38..edc83c41b9f1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; -import { ContextPreviewHover, type ContextPreviewPlacement } from "./contextPresentationRegistry"; +import { ContextPreviewHover, type ContextPreviewPlacement } from "./contextPreviewRegistry"; import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; import { ThreadTerminalStatusButton } from "./ThreadStatusIndicators"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; @@ -149,7 +149,6 @@ import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { selectProjectGroupingSettings } from "../logicalProject"; import { - countSidebarProjectChoices, projectGroupsSpanEnvironments, resolveSidebarProjectScopeKey, getSidebarProjectSettingsKey, @@ -1984,13 +1983,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {agentIndicator} - {/* Status at rest, hover actions once they fit. The pin is the - trailing item, outside that swap, so it cannot slide sideways. */} + {/* Left of the status slot, so hover swapping the label for the + wider row controls does not slide the pin. */} + {pinIndicator} {sortable?.isDragging ? ( - <> - {dragDestination} - {pinIndicator} - + dragDestination ) : ( ) : null} - {pinIndicator} )} {props.sweepAction !== null ? dragDestination : null} @@ -4047,7 +4043,7 @@ export default function Sidebar() { false, Schema.Boolean, ); - const projectChoiceCount = countSidebarProjectChoices(projectGroups); + const projectChoiceCount = projectGroups.length; const toggleWorkingShelf = () => setWorkingShelfExpanded((value) => !value); const visibleWorkingThreads = (() => { if (workingShelfExpanded) return workingThreads; diff --git a/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx b/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx deleted file mode 100644 index a8397d011916..000000000000 --- a/apps/web/src/components/SidebarStageBackdrop.browser.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { StageBackdropArt } from "./SidebarStageBackdrop"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -// Gradients and patterns resolve url(#id) document-wide, so a repeated id would paint one -// backdrop with another's definitions. -it.each(["nightly", "dev"] as const)( - "gives every %s backdrop its own SVG definitions when two are on the page", - async (variant) => { - await act(() => - root.render( - <> - - - , - ), - ); - const [first, second] = container.querySelectorAll("svg"); - const ids = (svg: Element | undefined) => [...svg!.querySelectorAll("[id]")].map((el) => el.id); - - expect(ids(first).length).toBeGreaterThan(0); - for (const id of ids(first)) { - expect(document.querySelectorAll(`#${CSS.escape(id)}`)).toHaveLength(1); - } - expect(ids(second).filter((id) => ids(first).includes(id))).toEqual([]); - for (const reference of second!.querySelectorAll("[fill^='url(#'], [filter^='url(#']")) { - const target = (reference.getAttribute("fill") ?? reference.getAttribute("filter"))!.slice( - 5, - -1, - ); - expect(second!.querySelector(`#${CSS.escape(target)}`)).not.toBeNull(); - } - }, -); diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx deleted file mode 100644 index 8f286758ebe2..000000000000 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - resolveEnvironmentIdentificationPillLabel, - resolveSidebarStageBackdropVariant, -} from "./SidebarStageBackdrop"; - -describe("SidebarStageBackdrop", () => { - it("resolves stage artwork only when enabled", () => { - expect(resolveSidebarStageBackdropVariant("Dev")).toBe("dev"); - expect(resolveSidebarStageBackdropVariant("Nightly")).toBe("nightly"); - expect(resolveSidebarStageBackdropVariant("Dev", false)).toBeNull(); - expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull(); - }); - - it("resolves supported environment pill labels", () => { - expect(resolveEnvironmentIdentificationPillLabel("Dev")).toBe("Dev"); - expect(resolveEnvironmentIdentificationPillLabel("nightly")).toBe("Nightly"); - expect(resolveEnvironmentIdentificationPillLabel("Latest")).toBeNull(); - expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); - }); -}); diff --git a/apps/web/src/components/ThreadContextChip.tsx b/apps/web/src/components/ThreadContextChip.tsx index 0444ed7bd96b..3df9eb156156 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -1,10 +1,12 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { ThreadContextRecord } from "@t3tools/contracts"; import { Link } from "@tanstack/react-router"; import { MessagesSquareIcon } from "lucide-react"; import type { ComponentProps } from "react"; -import { useThreadShell } from "~/state/entities"; +import { useProject, useThreadShell } from "~/state/entities"; import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { ProjectFavicon } from "./ProjectFavicon"; /** A referenced thread's chip, titled as the thread is now. The thread preview is its hover. */ export function ThreadChipTrigger({ @@ -19,6 +21,7 @@ export function ThreadChipTrigger({ }) { const { environmentId, threadId } = record; const shell = useThreadShell({ environmentId, threadId }); + const project = useProject(shell ? scopeProjectRef(environmentId, shell.projectId) : null); const title = shell?.title?.trim() || record.title; return ( - + {project ? ( + + ) : ( + + )} {title} ); } + +export const ThreadContextChip = ThreadChipTrigger; diff --git a/apps/web/src/components/ThreadHoverCard.tsx b/apps/web/src/components/ThreadHoverCard.tsx index cbfa63e690b3..092bf9b2d7c8 100644 --- a/apps/web/src/components/ThreadHoverCard.tsx +++ b/apps/web/src/components/ThreadHoverCard.tsx @@ -28,7 +28,7 @@ import { import { useEnvironmentQuery } from "~/state/query"; import { useThreadRunningTerminalIds } from "~/state/terminalSessions"; import { vcsEnvironment } from "~/state/vcs"; -import { resolveCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { PreviewCornerAction } from "./chat/PreviewCornerAction"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; @@ -203,7 +203,7 @@ function LiveThreadPreview(props: ThreadPreviewProps & { shell: EnvironmentThrea const previousProviderNames = thread.providerInstanceHistory .filter((instanceId) => instanceId !== modelInstanceId) .map((instanceId) => providerEntries.get(instanceId)?.displayName ?? instanceId); - const branchMismatch = resolveCheckoutBranchMismatch({ + const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, activeThreadBranch: thread.branch, @@ -344,3 +344,23 @@ function ReadThreadPreview(props: ThreadPreviewProps & { read: ThreadReadSnapsho ); } + +export function ThreadHoverCard({ + title, + children, + footer, +}: { + title: string; + children: ReactNode; + footer?: ReactNode; +}) { + return ( +
+
+ {title} +
+
{children}
+ {footer} +
+ ); +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 690a39329de0..1ec74cc98fb8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -3,9 +3,9 @@ import { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { render, stubGlobal, type Screen } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -96,7 +96,6 @@ const thread = { session: null as { status: string } | null, latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, }; -let root: Root; let focused = false; let visibility = "visible"; @@ -166,25 +165,14 @@ function shell(overrides: Partial = {}, ...others: Partial - root.render( - - - , - ), - ); -} - -beforeEach(() => { +// The mocked modules read `state`, the registry and the focus flags, so every test starts by +// resetting them. The first render is left to the test because some tests change state first. +function setup() { vi.clearAllMocks(); + const stack = new AsyncDisposableStack(); registry = AtomRegistry.make(); + const testRegistry = registry; + stack.defer(() => testRegistry.dispose()); state.mode = "notifications"; state.inApp = false; state.environmentIds = ["one", "two"]; @@ -194,157 +182,37 @@ beforeEach(() => { visibility = "visible"; TestNotification.permission = "granted"; TestNotification.sent = []; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("Notification", TestNotification); - vi.spyOn(window, "focus").mockImplementation(() => undefined); - vi.spyOn(document, "hasFocus").mockImplementation(() => focused); - vi.spyOn(document, "visibilityState", "get").mockImplementation( - () => visibility as DocumentVisibilityState, + stack.use(stubGlobal("Notification", TestNotification)); + stack.use(vi.spyOn(window, "focus").mockImplementation(() => undefined)); + stack.use(vi.spyOn(document, "hasFocus").mockImplementation(() => focused)); + stack.use( + vi + .spyOn(document, "visibilityState", "get") + .mockImplementation(() => visibility as DocumentVisibilityState), ); - root = createRoot(document.createElement("div")); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - delete window.desktopBridge; - registry.dispose(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -it("uses bounded tags across threads and environments, replaces repeat alerts, and clears on focus", async () => { - const environmentId = "fd6fff30-f90b-4ee7-9812-4cf497791b90"; - const otherEnvironmentId = "25b62992-b996-4ee3-8d82-d988840e45c1"; - const threadId = "a8395a98-a186-4fe7-9d4c-fb8ba63b7588"; - const otherThreadId = "257598ad-ae4f-4422-a073-e03f4b4f5f0d"; - const completed = (id: string, completedAt = "2026-09-13T08:00:00Z") => ({ - id, - latestTurn: { turnId: "turn", state: "completed", completedAt }, - }); - state.environmentIds = [environmentId, otherEnvironmentId]; - state.shells.set(environmentId, shell({ id: threadId }, { id: otherThreadId })); - state.shells.set(otherEnvironmentId, shell({ id: threadId })); - await render(); - state.shells.set(environmentId, shell(completed(threadId), { id: otherThreadId })); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); - state.shells.set(environmentId, shell(completed(threadId), completed(otherThreadId))); - state.shells.set(otherEnvironmentId, shell(completed(threadId))); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(3); - state.shells.set( - environmentId, - shell(completed(threadId, "2026-09-13T08:01:00Z"), completed(otherThreadId)), + const ui = () => ( + + + ); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(3); - expect(TestNotification.sent).toHaveLength(4); - const [first, otherThread, otherEnvironment, replacement] = TestNotification.sent; - for (const notification of TestNotification.sent) { - expect(notification.tag).toMatch(/^[0-9a-f]{16}$/); - } - expect(new Set([first!.tag, otherThread!.tag, otherEnvironment!.tag]).size).toBe(3); - expect(replacement!.tag).toBe(first!.tag); - expect(first!.close).toHaveBeenCalledOnce(); - expect(otherThread!.close).not.toHaveBeenCalled(); - expect(otherEnvironment!.close).not.toHaveBeenCalled(); - expect(replacement!.close).not.toHaveBeenCalled(); - focused = true; - window.dispatchEvent(new Event("focus")); - expect(state.badge).toHaveBeenLastCalledWith(0); - expect( - TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0), - ).toBe(true); - focused = false; - state.shells.set(otherEnvironmentId, shell(completed(threadId, "2026-09-13T08:02:00Z"))); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); -}); - -it("does not badge old completions on first load or reconnect", async () => { - complete(); - await render(); - state.shells.set("one", { status: "connecting", snapshot: Option.none() }); - await render(); - complete("one", "2026-09-13T08:01:00Z"); - await render(); - expect(TestNotification.sent).toHaveLength(0); - expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); -}); - -it("removes alerts only from environments that leave the client", async () => { - await render(); - complete("one"); - complete("two"); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(2); - const [removed, retained] = TestNotification.sent; - state.environmentIds = ["two"]; - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); - expect(removed!.close).toHaveBeenCalledOnce(); - expect(retained!.close).not.toHaveBeenCalled(); - await render(); - expect(removed!.close).toHaveBeenCalledOnce(); - state.environmentIds = []; - await render(); - expect(state.badge).toHaveBeenLastCalledWith(0); - expect(retained!.close).toHaveBeenCalledOnce(); -}); - -it("starts a fresh count after another native app window gains focus", async () => { - let clear: (() => void) | undefined; - const unsubscribe = vi.fn(); - Object.assign(window, { - desktopBridge: { - onNotificationBadgeClear: (listener: () => void) => { - clear = listener; - return unsubscribe; - }, + let screen: Screen | undefined; + return { + async render() { + if (screen) await screen.rerender(ui()); + else screen = stack.use(await render(ui())); }, - }); - await render(); - complete(); - await render(); - clear!(); - expect(state.badge).toHaveBeenLastCalledWith(0); - complete("two"); - await render(); - expect(state.badge).toHaveBeenLastCalledWith(1); - await act(async () => root.unmount()); - root = createRoot(document.createElement("div")); - expect(unsubscribe).toHaveBeenCalledOnce(); - expect(state.badge).toHaveBeenLastCalledWith(0); -}); - -it.each(["off", "sound", "focused", "denied", "archived"])( - "does not show visual alerts when %s", - async (condition) => { - if (condition === "off" || condition === "sound") state.mode = condition; - if (condition === "focused") focused = true; - if (condition === "denied") TestNotification.permission = "denied"; - await render(); - complete(); - if (condition === "archived") - state.shells.set( - "one", - shell({ - archivedAt: "2026-09-13T08:00:00Z", - hasPendingApprovals: true, - }), - ); - await render(); - expect(TestNotification.sent).toHaveLength(0); - expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); - }, -); + unmount: () => screen!.unmount(), + [Symbol.asyncDispose]: () => stack.disposeAsync(), + }; +} it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "badges %s and clears when notifications are disabled", async (flag) => { - await render(); + await using app = setup(); + await app.render(); state.shells.set("one", shell({ [flag]: true })); - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(1); const notification = TestNotification.sent[0]!; notification.dispatchEvent(new Event("click")); @@ -353,29 +221,8 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, }); state.mode = "sound"; - await render(); + await app.render(); expect(state.badge).toHaveBeenLastCalledWith(0); expect(notification.close).toHaveBeenCalled(); }, ); - -it("shows in-app alerts without adding a badge while focused", async () => { - state.inApp = true; - focused = true; - await render(); - complete(); - await render(); - expect(state.toast).toHaveBeenCalledOnce(); - expect(TestNotification.sent).toHaveLength(0); - expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); -}); - -it("badges background failures with in-app notifications enabled", async () => { - state.inApp = true; - await render(); - state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } })); - await render(); - expect(TestNotification.sent[0]?.title).toBe("Thread failed"); - expect(state.badge).toHaveBeenLastCalledWith(1); - expect(state.toast).not.toHaveBeenCalled(); -}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 44dc85fbb4b6..0380a4a691f0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -188,7 +188,11 @@ function EnvironmentNotifications({ ) { candidates.push(status); } - if (completion !== null && (prior.completion === null || completion > prior.completion)) { + if ( + !rawThread.latestRunStopRequested && + completion !== null && + (prior.completion === null || completion > prior.completion) + ) { candidates.push("completed"); } if (hasNewPullRequestNews(prior.pullRequestNews, pullRequestNews)) { diff --git a/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx b/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx deleted file mode 100644 index 09d3c3823105..000000000000 --- a/apps/web/src/components/ThreadStatusIndicators.subscriptions.browser.test.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { - ProjectId, - ThreadId, - type ThreadPullRequestLink, - type VcsStatusResult, -} from "@t3tools/contracts"; -import { act, type MouseEvent } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; - -import "../index.css"; -import { Button } from "./ui/button"; - -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { makeThreadFixture } from "../test-fixtures"; - -const state = vi.hoisted(() => ({ queries: [] as string[] })); - -vi.mock(import("../state/entities"), async (original) => ({ - ...(await original()), - useProject: () => null, - useServerConfigs: () => new Map(), -})); -vi.mock(import("../state/vcs"), async (original) => { - const { AsyncResult, Atom } = await import("effect/reactivity"); - const statuses = Atom.family((cwd: string) => - Atom.make(() => { - state.queries.push(`vcs:${cwd}`); - return AsyncResult.success({ - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/test", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: { - number: 42, - title: "Observed worktree PR", - url: "https://github.com/example/repo/pull/42", - baseRef: "main", - headRef: "feature/test", - state: "open", - }, - } satisfies VcsStatusResult); - }), - ); - const actual = await original(); - return { - ...actual, - vcsEnvironment: { - ...actual.vcsEnvironment, - status: (({ input }: { input: { cwd: string } }) => statuses(input.cwd)) as never, - }, - }; -}); -vi.mock(import("../state/pullRequests"), async (importOriginal) => { - const original = await importOriginal(); - const { AsyncResult, Atom } = await import("effect/reactivity"); - const { ProjectId } = await import("@t3tools/contracts"); - const summaries = Atom.family((number: number) => - Atom.make(() => { - state.queries.push(`pr:${number}`); - return AsyncResult.success({ - provider: "github" as const, - projectId: ProjectId.make("project-test"), - repository: "example/repo", - number, - title: "Observed linked PR", - url: `https://github.com/example/repo/pull/${number}`, - state: "open" as const, - headBranch: "feature/test", - baseBranch: "main", - updatedAt: "2026-09-04T00:00:00.000Z", - }); - }), - ); - return { - ...original, - linkedPullRequestDetailAtom: (({ input }: { input: { number: number } }) => - summaries(input.number)) as never, - }; -}); -import { ThreadPullRequestBadgeControl, ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -// The lease overscans 160px around a 20px viewport, a 340px band. With 45px rows the band -// covers exactly 8 rows at each scroll position below. -const ROW_HEIGHT = 45; -const SPACER = 1000; - -it("leases only visible palette results while retaining observed and cached PR badges", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const threads = Array.from({ length: 128 }, (_, index) => - makeThreadFixture({ - id: ThreadId.make(`palette-thread-${index}`), - branch: "feature/test", - worktreePath: `/test/worktree-${index}`, - linkedPullRequest: - index % 2 === 0 - ? null - : { - projectId: ProjectId.make("project-test"), - repository: "example/repo", - number: index, - url: `https://github.com/example/repo/pull/${index}`, - }, - }), - ); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - state.queries.length = 0; - const viewport = () => - container.querySelector('[data-slot="scroll-area-viewport"]')!; - const scrollTo = async (top: number) => { - await act(async () => { - viewport().scrollTop = top; - await new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 50))); - }); - }; - const badgeCount = () => - [...container.querySelectorAll("[aria-label]")].filter((node) => - node.getAttribute("aria-label")!.startsWith("PR #"), - ).length; - try { - await act(async () => - root.render( - -
-
- {threads.map((thread, index) => ( -
- - {thread.title} -
- ))} -
- , - ), - ); - await scrollTo(0); - expect(state.queries).toEqual([]); - expect(badgeCount()).toBe(1); - - // Band [1000, 1340]: rows 0..7. - await scrollTo(SPACER + 160); - expect(state.queries).toHaveLength(8); - expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); - expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); - expect(badgeCount()).toBe(9); - - // Band [1370, 1710]: rows 8..15. - await scrollTo(SPACER + 8 * ROW_HEIGHT + 10 + 160); - expect(state.queries).toHaveLength(16); - expect(badgeCount()).toBe(17); - } finally { - await act(async () => root.unmount()); - container.remove(); - } -}); - -it("keeps a long PR hover list in the viewport and lets the last row be selected", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const previousViewport = { width: window.innerWidth, height: window.innerHeight }; - await page.viewport(800, 480); - const pullRequests = Array.from({ length: 40 }, (_, index): ThreadPullRequestLink => ({ - host: "github.com", - repository: "example/repo", - number: index + 1, - url: `https://github.com/example/repo/pull/${index + 1}`, - source: "manual", - linkedAt: "2026-01-01T00:00:00Z", - snapshot: null, - stack: null, - })); - const onOpenPullRequest = vi.fn((event: MouseEvent) => event.preventDefault()); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - try { - await act(async () => - root.render( -
- } - badge={{ kind: "pull-request", others: 39, state: "open" }} - pullRequests={pullRequests} - number={1} - url={pullRequests[0]!.url} - status={null} - onOpenList={() => {}} - onOpenPullRequest={onOpenPullRequest} - /> -
, - ), - ); - await act(() => userEvent.hover(container.querySelector("button")!)); - const popup = () => document.querySelector('[data-slot="tooltip-popup"]')!; - await expect.poll(() => popup()?.querySelectorAll("a").length).toBe(40); - await expect.poll(() => popup().getBoundingClientRect().top).toBeGreaterThanOrEqual(0); - await expect - .poll(() => popup().getBoundingClientRect().bottom) - .toBeLessThanOrEqual(window.innerHeight); - - const lastRow = popup().querySelector("li:last-child a")!; - expect(lastRow.getBoundingClientRect().top).toBeGreaterThan( - popup().getBoundingClientRect().bottom, - ); - await act(() => userEvent.wheel(popup(), { delta: { y: 10_000 } })); - await expect - .poll(() => lastRow.getBoundingClientRect().bottom) - .toBeLessThanOrEqual(popup().getBoundingClientRect().bottom); - expect(lastRow.getBoundingClientRect().top).toBeGreaterThanOrEqual( - popup().getBoundingClientRect().top, - ); - await act(() => userEvent.click(lastRow)); - expect(onOpenPullRequest).toHaveBeenCalledExactlyOnceWith(expect.anything(), lastRow.href); - } finally { - await act(async () => root.unmount()); - container.remove(); - await page.viewport(previousViewport.width, previousViewport.height); - } -}); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 976a220703c0..8ef5d75269b1 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -3,49 +3,17 @@ import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3too import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { AtomRegistry } from "effect/reactivity"; -import type { AnimationEvent } from "react"; import { - ChangeRequestStatusIcon, nextThreadChangeRequestSnapshot, - prStatusIndicator, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, resolveThreadPr, threadChangeRequestSnapshotsEqual, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, - resolveThreadPullRequestBadgePresentation, - synchronizeTerminalPulse, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; - -describe("synchronizeTerminalPulse", () => { - it("pins only the status pulse to the document clock", () => { - const pulse = { animationName: "status-pulse", startTime: 975 } as CSSAnimation; - const otherCss = { animationName: "other-animation", startTime: 125 } as CSSAnimation; - const otherAnimation = { startTime: 250 } as Animation; - - synchronizeTerminalPulse({ - animationName: "status-pulse", - currentTarget: { getAnimations: () => [pulse, otherCss, otherAnimation] }, - } as AnimationEvent); - - expect([pulse.startTime, otherCss.startTime, otherAnimation.startTime]).toEqual([0, 125, 250]); - }); -}); - -describe("ChangeRequestStatusIcon", () => { - it.each([ - ["open", "open", false, PullRequestGlyph.pullRequest], - ["draft", "open", true, PullRequestGlyph.draft], - ["closed", "closed", false, PullRequestGlyph.closed], - ["merged", "merged", false, PullRequestGlyph.merged], - ] as const)("uses the %s pull request glyph", (_label, state, isDraft, expectedIcon) => { - expect(ChangeRequestStatusIcon({ state, isDraft }).type).toBe(expectedIcon); - }); -}); function status(overrides: Partial = {}): VcsStatusResult { return { @@ -108,13 +76,6 @@ function pullRequestSummary( } describe("shared pull request state", () => { - it("shows a panel-observed merge instead of an older sidebar summary", () => { - const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); - const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); - - expect(newestPullRequestSummary(open, merged)).toBe(merged); - }); - it("never lets a stale open response regress a merged observation", () => { const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); @@ -131,15 +92,6 @@ describe("shared pull request state", () => { }); describe("resolveThreadPr", () => { - it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "feature/other", - gitStatus: status(), - }), - ).toBeNull(); - }); - it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { expect( resolveThreadPr({ @@ -148,26 +100,6 @@ describe("resolveThreadPr", () => { }), ).toBeNull(); }); - - it("hides PR indicators when thread branch metadata is missing", () => { - expect( - resolveThreadPr({ - threadBranch: null, - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("shows the PR when the live checkout matches the stored thread branch", () => { - const gitStatus = status(); - - expect( - resolveThreadPr({ - threadBranch: "feature/current", - gitStatus, - }), - ).toBe(gitStatus.pr); - }); }); describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { @@ -185,31 +117,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { baseUrl: "https://github.com", }; - it("returns the live merged PR when the checkout matches the feature branch", () => { - const gitStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBe(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); - }); - it("shows a linked pull request when the checkout has a different branch", () => { const linkedPullRequestStatus = { pr: mergedPr, @@ -457,19 +364,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toBeNull(); }); - it("retains a local terminal snapshot when thread metadata follows the new branch", () => { - const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: otherBranchSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); @@ -504,19 +398,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toBeNull(); }); - it("clears an open snapshot when a local checkout moves to a different branch", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - it("clears a retained snapshot when the thread branch is cleared", () => { const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); @@ -586,31 +467,6 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toBeNull(); }); - it("retains a merged PR after a main checkout", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).not.toBeNull(); - expect(cached).not.toBeUndefined(); - - const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); - const displayed = resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }); - expect(displayed?.state).toBe("merged"); - }); - it("refreshes a cached snapshot when a pull request becomes ready", () => { const readyPr = { ...mergedPr, state: "open" as const }; const draftPr = { ...readyPr, isDraft: true }; @@ -645,162 +501,3 @@ describe("threadChangeRequestSnapshotsAtom", () => { }), ); }); - -describe("prStatusIndicator", () => { - it("formats PR tooltips with number, uppercase status, and title", () => { - expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ - tooltip: "PR #42 - Open: PR branch", - tooltipLead: "PR #42 - Open", - tooltipTitle: "PR branch", - }); - }); - - it("uses red for closed pull requests", () => { - const closedPr = status().pr; - if (!closedPr) throw new Error("Expected pull request fixture"); - - expect(prStatusIndicator({ ...closedPr, state: "closed" }, undefined)?.colorClass).toContain( - "text-red-600", - ); - }); - - it("uses gray and draft wording for draft pull requests", () => { - const draftPr = status().pr; - if (!draftPr) throw new Error("Expected pull request fixture"); - - expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({ - label: "PR draft", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltipLead: "PR #42 - Draft", - }); - }); -}); - -describe("resolveThreadPullRequestBadgePresentation", () => { - const url = "https://github.com/pingdotgg/t3code/pull/42"; - - it("returns the pending pull-request badge when no snapshot is available", () => { - expect( - resolveThreadPullRequestBadgePresentation({ - badge: null, - number: 42, - url, - status: null, - }), - ).toEqual({ - Icon: PullRequestGlyph.pullRequest, - toneClassName: "text-muted-foreground", - label: "PR #42, status pending", - text: 42, - }); - }); - - it.each([ - [ - "open", - { state: "open", isDraft: false }, - PullRequestGlyph.pullRequest, - "text-emerald-600 dark:text-emerald-300/90", - "PR #42 - Open: PR branch", - ], - [ - "draft", - { state: "open", isDraft: true }, - PullRequestGlyph.draft, - "text-zinc-500 dark:text-zinc-400/80", - "PR #42 - Draft: PR branch", - ], - [ - "closed", - { state: "closed", isDraft: false }, - PullRequestGlyph.closed, - "text-red-600 dark:text-red-300/90", - "PR #42 - Closed: PR branch", - ], - [ - "merged", - { state: "merged", isDraft: false }, - PullRequestGlyph.merged, - "text-violet-600 dark:text-violet-300/90", - "PR #42 - Merged: PR branch", - ], - ] as const)( - "keeps the %s state for one linked pull request", - (_state, prOverrides, expectedIcon, expectedToneClassName, expectedLabel) => { - const fixture = status().pr; - if (!fixture) throw new Error("Expected pull request fixture"); - const prStatus = prStatusIndicator({ ...fixture, ...prOverrides }, undefined); - if (!prStatus) throw new Error("Expected pull request status"); - - expect( - resolveThreadPullRequestBadgePresentation({ - badge: { kind: "pull-request", others: 0, state: "open" }, - number: fixture.number, - url: fixture.url, - status: prStatus, - }), - ).toEqual({ - Icon: expectedIcon, - toneClassName: expectedToneClassName, - label: expectedLabel, - text: fixture.number, - }); - }, - ); - - it.each([ - ["open", "text-emerald-600 dark:text-emerald-300/90"], - ["draft", "text-zinc-500 dark:text-zinc-400/80"], - ["merged", "text-violet-600 dark:text-violet-300/90"], - ] as const)( - "uses a layers badge with the %s stack tone without a link identity", - (state, expectedToneClassName) => { - expect( - resolveThreadPullRequestBadgePresentation({ - badge: { kind: "stack", layers: 3, state }, - status: null, - }), - ).toEqual({ - Icon: PullRequestGlyph.stack, - toneClassName: expectedToneClassName, - label: `Stack of 3 pull requests, ${state}`, - text: 3, - }); - }, - ); - - it.each([ - ["open", PullRequestGlyph.pullRequest, "text-emerald-600 dark:text-emerald-300/90"], - ["draft", PullRequestGlyph.draft, "text-zinc-500 dark:text-zinc-400/80"], - ["merged", PullRequestGlyph.merged, "text-violet-600 dark:text-violet-300/90"], - ] as const)( - "draws the count of unrelated linked pull requests with their %s aggregate state", - (state, expectedIcon, expectedToneClassName) => { - const fixture = status().pr; - if (!fixture) throw new Error("Expected pull request fixture"); - const closedStatus = prStatusIndicator( - { ...fixture, state: "closed", isDraft: false }, - undefined, - ); - if (!closedStatus) throw new Error("Expected pull request status"); - - expect( - resolveThreadPullRequestBadgePresentation({ - badge: { kind: "pull-request", others: 2, state }, - number: fixture.number, - url: fixture.url, - status: closedStatus, - }), - ).toEqual({ - Icon: expectedIcon, - toneClassName: expectedToneClassName, - label: `PR #42 - Closed: PR branch, and 2 more linked; overall ${state}`, - text: "+3", - }); - }, - ); - - it("omits the control when neither a stack nor a linked identity can be shown", () => { - expect(resolveThreadPullRequestBadgePresentation({ badge: null, status: null })).toBeNull(); - }); -}); diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx deleted file mode 100644 index d42043b847be..000000000000 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ThreadPullRequestLink } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; - -describe("linked pull request snapshots", () => { - const link: ThreadPullRequestLink = { - host: "gitlab.example.com", - repository: "acme/web", - number: 42, - url: "https://gitlab.example.com/acme/web/-/merge_requests/42", - source: "manual", - linkedAt: "2026-01-01T00:00:00Z", - stack: null, - snapshot: null, - }; - it("keeps unsynced links unknown", () => { - expect(linkedPullRequestSnapshotStatus(link)).toBeNull(); - }); - it("uses the snapshot state and branches with the linked identity", () => { - const result = linkedPullRequestSnapshotStatus({ - ...link, - snapshot: { - state: "merged", - title: "Change", - headBranch: "feature", - baseBranch: "main", - isDraft: false, - updatedAt: "2026-01-02T00:00:00Z", - syncedAt: "2026-01-03T00:00:00Z", - }, - }); - expect(result).toEqual({ - pr: { - number: 42, - url: link.url, - title: "Change", - state: "merged", - isDraft: false, - headRef: "feature", - baseRef: "main", - updatedAt: "2026-01-02T00:00:00Z", - }, - sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, - }); - }); - it.each([ - ["https://git.cafe/owner/repo/pulls/42", "gitcafe"], - ["https://staging.git.cafe/owner/repo/pulls/42", "gitcafe"], - ["https://codeberg.org/owner/repo/pulls/42", "forgejo"], - ["https://gitlab.example.com/acme/web/-/merge_requests/42", "gitlab"], - ["https://github.com/owner/repo/pull/42", "github"], - ])("classifies %s as %s", (url, kind) => { - const result = linkedPullRequestSnapshotStatus({ - ...link, - url, - snapshot: { - state: "open", - title: "Change", - headBranch: "feature", - baseBranch: "main", - isDraft: false, - updatedAt: null, - syncedAt: "2026-01-03T00:00:00Z", - }, - }); - expect(result?.sourceControlProvider.kind).toBe(kind); - }); -}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 527865c2adec..655dbe566649 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -24,18 +24,13 @@ import { pullRequestListLines, resolveThreadCurrentPullRequestLink, type PullRequestListLine, + resolveThreadPullRequestBadge, resolveThreadPullRequestChains, type ThreadPullRequestBadge, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; import { useRender } from "@base-ui/react/use-render"; -import { - type ReactNode, - type AnimationEvent, - type MouseEvent, - type ReactElement, - type ComponentProps, -} from "react"; +import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { revealThreadTerminal } from "../lib/revealThreadTerminal"; @@ -165,7 +160,7 @@ export interface ThreadPullRequestBadgePresentation { readonly text: string | number; } -export function resolveThreadPullRequestBadgePresentation({ +function resolveThreadPullRequestBadgePresentation({ badge, number, url, @@ -792,7 +787,7 @@ export function terminalStatusFromRunningIds( }; } -export function synchronizeTerminalPulse(event: AnimationEvent) { +function synchronizeTerminalPulse(event: AnimationEvent) { if (event.animationName !== "status-pulse") return; for (const animation of event.currentTarget.getAnimations()) { @@ -1096,24 +1091,11 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma ); } -function ThreadPullRequestNumber({ - number, - className, - ...props -}: ComponentProps<"span"> & { number: number }) { - return ( - - #{number} - - ); -} - -function settledPrHoverColorClass(state: NonNullable["state"], isDraft = false): string { +function settledPrHoverColorClass(state: ThreadPullRequestBadge["state"]): string { switch (state) { + case "draft": + return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; case "open": - if (isDraft) { - return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80"; - } return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90"; case "merged": return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90"; @@ -1122,6 +1104,7 @@ function settledPrHoverColorClass(state: NonNullable["state"], isDraft } } +// Search rows are already buttons, so this renders the sidebar badge's look without its controls. export function ThreadSearchPullRequestNumber({ thread, query, @@ -1133,40 +1116,58 @@ export function ThreadSearchPullRequestNumber({ enabled?: boolean; settled?: boolean; }) { - const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) => - pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query), - ); - const fallbackCandidates = - thread.pullRequests.length === 0 - ? [thread.linkedPullRequest, thread.branchPullRequest] - : [thread.branchPullRequest]; - const legacyReference = matchedLink - ? undefined - : fallbackCandidates.find( - (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query), - ); - const legacyStatus = useLinkedThreadPullRequest( + const matched = pullRequestMatchesSearchQuery(thread, query); + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const linked = useLinkedThreadPullRequest( thread.environmentId, - legacyReference, - enabled, - undefined, - legacyReference, + thread.linkedPullRequest, + enabled && matched, + thread.pullRequests, + thread.branchPullRequest, ); - const reference = matchedLink ?? legacyReference; - const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus; - const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider); - return reference ? ( - + reference != null && pullRequestMatchesSearchQuery({ linkedPullRequest: reference }, query), + ); + const presentation = resolveThreadPullRequestBadgePresentation({ + badge, + number: pr?.number ?? stored?.number, + url: pr?.url ?? stored?.url, + status: prStatusIndicator(pr, linked?.sourceControlProvider), + }); + if (presentation === null) return null; + const aggregate = badge !== null && (badge.kind === "stack" || badge.others > 0); + const state = aggregate + ? badge.state + : pr === null + ? null + : pr.state === "open" && pr.isDraft + ? "draft" + : pr.state; + return ( + - ) : null; + ? cn("text-secondary-label transition-colors", state && settledPrHoverColorClass(state)) + : presentation.toneClassName, + )} + > + {/* text-current opts out of the command palette's muted icon color. */} + + {presentation.text} + + ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx deleted file mode 100644 index 8883e768f672..000000000000 --- a/apps/web/src/components/ThreadTerminalDrawer.permissions.browser.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - AuthTerminalOperateScope, - EnvironmentId, - ThreadId, - type AuthEnvironmentScope, -} from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal"; -import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import type { - GhosttyTerminalSurface, - GhosttyTerminalSurfaceOptions, -} from "~/terminal/ghostty/surface"; - -const state = vi.hoisted(() => ({ - allowed: true, - listeners: new Set<() => void>(), - resize: vi.fn(), - otherCommand: vi.fn(), - createSurface: - vi.fn< - ( - mount: HTMLElement, - options: GhosttyTerminalSurfaceOptions, - ) => Promise - >(), -})); - -vi.mock(import("@effect/atom-react"), async (original) => ({ - ...(await original()), - useAtomValue: (() => null) as never, -})); -vi.mock(import("../hooks/useSettings"), async (original) => ({ - ...(await original()), - getClientSettings: () => DEFAULT_CLIENT_SETTINGS, - useClientSettings: ((select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - select(DEFAULT_CLIENT_SETTINGS)) as never, -})); -vi.mock(import("../editorPreferences"), async (original) => ({ - ...(await original()), - useOpenInPreferredEditor: (() => state.otherCommand) as never, -})); -vi.mock(import("../state/terminal"), async (original) => { - const actual = await original(); - return { - ...actual, - terminalEnvironment: { ...actual.terminalEnvironment, resize: "resize" as never }, - }; -}); -vi.mock(import("../state/use-atom-command"), async (original) => ({ - ...(await original()), - useAtomCommand: ((command: unknown) => - command === "resize" ? state.resize : state.otherCommand) as never, -})); -vi.mock(import("../state/terminalSessions"), async (original) => ({ - ...(await original()), - useAttachedTerminalSession: (() => session) as never, -})); -vi.mock(import("~/terminal/ghostty/surface"), async (original) => ({ - ...(await original()), - GhosttyTerminalSurface: { create: state.createSurface } as never, -})); -vi.mock(import("../state/session"), async (original) => { - const { useSyncExternalStore } = await import("react"); - const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => - id !== null && - (scope !== AuthTerminalOperateScope || id !== threadRef.environmentId || state.allowed); - return { - ...(await original()), - readEnvironmentScope, - useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => - useSyncExternalStore( - (listener) => { - state.listeners.add(listener); - return () => state.listeners.delete(listener); - }, - () => readEnvironmentScope(id, scope), - ), - }; -}); - -import ThreadTerminalDrawer, { TerminalViewport } from "./ThreadTerminalDrawer"; - -const threadRef = { - environmentId: EnvironmentId.make("secondary-terminal"), - threadId: ThreadId.make("thread"), -}; -const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 }; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - state.allowed = true; - state.listeners.clear(); - state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.otherCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - // A surface can report its initial grid while asynchronous WASM setup is - // pending. Keep that unrelated setup pending while exercising its callback. - state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined)); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => { - await act(async () => - root.render( - , - ), - ); - expect(state.createSurface).toHaveBeenCalledOnce(); - const onResize = state.createSurface.mock.calls[0]![1].onResize; - if (!onResize) throw new Error("The terminal did not register its resize callback."); - - onResize(80, 24); - expect(state.resize).toHaveBeenCalledExactlyOnceWith({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 80, rows: 24 }, - }); - - state.allowed = false; - onResize(120, 40); - expect(state.resize).toHaveBeenCalledTimes(1); - - state.allowed = true; - onResize(100, 30); - expect(state.resize).toHaveBeenCalledTimes(2); - expect(state.resize).toHaveBeenLastCalledWith({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 100, rows: 30 }, - }); -}); - -it("keeps held Kitty keys and the original surface through split, focus, and unsplit", async () => { - const { GhosttyTerminalSurface } = await vi.importActual< - typeof import("~/terminal/ghostty/surface") - >("~/terminal/ghostty/surface"); - const output: string[][] = []; - state.createSurface.mockImplementation((mount, options) => { - const data: string[] = []; - output.push(data); - return GhosttyTerminalSurface.create(mount, { - ...options, - onData: (chunk) => { - data.push(chunk); - options.onData(chunk); - }, - }); - }); - const consumeShortcut = (event: KeyboardEvent) => { - if ((event.code === "KeyD" && event.ctrlKey) || (event.code === "KeyB" && event.repeat)) { - event.preventDefault(); - event.stopPropagation(); - } - }; - // ChatView installs its capture listener before the terminal mounts. - window.addEventListener("keydown", consumeShortcut, { capture: true }); - const composer = document.createElement("textarea"); - document.body.append(composer); - const render = async (terminalIds: string[]) => { - await act(async () => - root.render( - , - ), - ); - await act(async () => { - await Promise.all(state.createSurface.mock.results.map((result) => result.value)); - }); - }; - try { - await render(["original"]); - const original = await state.createSurface.mock.results[0]!.value; - const disposeOriginal = vi.spyOn(original, "dispose"); - original.write("\x1b[>11u"); - const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => - original.input.dispatchEvent( - new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }), - ); - original.focus(); - key("keydown", { key: "Control", code: "ControlLeft", ctrlKey: true }); - key("keydown", { key: "d", code: "KeyD", ctrlKey: true }); - await render(["original", "split"]); - expect(state.createSurface).toHaveBeenCalledTimes(2); - expect(container.querySelector("textarea")).toBe(original.input); - expect(disposeOriginal).not.toHaveBeenCalled(); - const split = await state.createSurface.mock.results[1]!.value; - const disposeSplit = vi.spyOn(split, "dispose"); - original.focus(); - key("keyup", { key: "d", code: "KeyD", ctrlKey: true }); - key("keyup", { key: "Control", code: "ControlLeft" }); - - key("keydown", { key: "b", code: "KeyB" }); - composer.focus(); - original.focus(); - key("keydown", { key: "b", code: "KeyB", repeat: true }); - key("keyup", { key: "b", code: "KeyB" }); - - key("keydown", { key: "c", code: "KeyC" }); - await render(["original"]); - expect(state.createSurface).toHaveBeenCalledTimes(2); - expect(container.querySelector("textarea")).toBe(original.input); - expect(disposeOriginal).not.toHaveBeenCalled(); - expect(disposeSplit).toHaveBeenCalledOnce(); - original.focus(); - key("keyup", { key: "c", code: "KeyC" }); - expect(output).toEqual([ - ["\x1b[57442;5u", "\x1b[57442;1:3u", "\x1b[98u", "\x1b[98;1:3u", "\x1b[99u", "\x1b[99;1:3u"], - [], - ]); - } finally { - window.removeEventListener("keydown", consumeShortcut, { capture: true }); - composer.remove(); - } -}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 9bd692f991a5..573619a18f4b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -12,9 +12,8 @@ import { resolveTerminalDrawerMaxHeight, shouldClearTerminalSelectionAction, shouldHandleTerminalExit, - terminalContextMenuItems, synchronizeTerminalOutput, - terminalSelectionLineRange, + terminalContextMenuItems, terminalSelectionMenuItems, terminalThemeFromApp, } from "./ThreadTerminalDrawer"; @@ -164,23 +163,6 @@ describe("terminalThemeFromApp", () => { }); describe("terminal selection actions", () => { - it("clears a pending or currently owned menu when the selection disappears", () => { - expect( - shouldClearTerminalSelectionAction({ - actionPending: true, - openMenuRequestId: null, - currentRequestId: 4, - }), - ).toBe(true); - expect( - shouldClearTerminalSelectionAction({ - actionPending: false, - openMenuRequestId: 4, - currentRequestId: 4, - }), - ).toBe(true); - }); - it("does not let an old selection popup cancel its replacement right-click menu", () => { expect( shouldClearTerminalSelectionAction({ @@ -198,25 +180,11 @@ describe("terminal selection actions", () => { ).toBe(false); }); - it("uses Ghostty's physical screen range for visually wrapped selections", () => { - expect( - terminalSelectionLineRange({ - start: { y: 4 }, - end: { y: 6 }, - }), - ).toEqual({ lineStart: 5, lineEnd: 7 }); - }); - it("handles an exit that lands while the terminal surface is still loading", () => { expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true); expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false); expect(shouldHandleTerminalExit("closed", "running", true, 1)).toBe(false); }); - - it.each(["closed", "exited"] as const)("ignores an unsynchronized %s seed", (status) => { - expect(shouldHandleTerminalExit(status, "running", false, 0)).toBe(false); - expect(shouldHandleTerminalExit(status, "running", false, 1)).toBe(true); - }); }); it("retains visible output and selection until a replacement subscription receives its snapshot", () => { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f2750f66bd7b..6f5a1fc7dcec 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -377,10 +377,10 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty }; } -export function terminalSelectionLineRange(position: { - start: { y: number }; - end: { y: number }; -}): { lineStart: number; lineEnd: number } { +function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number } }): { + lineStart: number; + lineEnd: number; +} { const lineStart = position.start.y + 1; return { lineStart, @@ -1261,7 +1261,7 @@ function TerminalActionButton({ render={ - ))} - {restingImagePreviewCounts.overflowCount > 0 ? ( - - ) : null} -
+ standaloneComposerImages.length > 0 ? ( + ) : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); @@ -5677,6 +4839,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerScrollCollapsed(false); } }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]); + // Returning to the window re-fires focus on the element that already held // it. That focus arrives after the window's own event, so a window focus // marks the frame in which it should be ignored by the form's capture. @@ -5698,8 +4861,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) windowRefocusInFlightRef.current = false; }; }, [isComposerScrollCollapsed]); + useEffect(() => { if (!canTrackComposerScrollGesture) return; + const finishScrollGesture = () => { if (composerScrollCollapseTimeoutRef.current !== null) { window.clearTimeout(composerScrollCollapseTimeoutRef.current); @@ -5711,6 +4876,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (event.ctrlKey || !(event.target instanceof Element)) { return; } + const scrollNode = getTimelineScrollableNode(); if (!scrollNode) return; const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); @@ -5719,6 +4885,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !composerScrollGestureRef.current.collapseSuppressed ) return; + if (composerScrollCollapseTimeoutRef.current !== null) { window.clearTimeout(composerScrollCollapseTimeoutRef.current); } @@ -5726,6 +4893,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) finishScrollGesture, COMPOSER_SCROLL_GESTURE_RESET_MS, ); + const canScrollInGestureDirection = targetsTimeline && (event.deltaY < 0 @@ -5749,8 +4917,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!shouldCollapse) { return; } + setIsComposerScrollCollapsed(true); }; + document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); return () => { document.removeEventListener("wheel", handleTimelineWheel, true); @@ -5762,6 +4932,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isTimelineAtLogicalEnd, setIsComposerScrollCollapsed, ]); + const restingHiddenBlockCount = composerControlsCollapsed ? restingControlsHiddenBlockCount : expandedControlsHiddenBlockCount; @@ -5773,50 +4944,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) size: composerControlsCollapsed ? "xs" : "sm", hidden: composerControlsHidden || restingHiddenBlockCount > 1, }); - const allProviderAccounts = (() => - accountsForProvider({ entries: providerInstanceEntries, driverKind: selectedProvider }))(); - const lockedProviderAccounts = (() => - lockedContinuationGroupKey != null - ? accountsForProvider({ - entries: providerInstanceEntries, - driverKind: selectedProvider, - lockedContinuationGroupKey, - }) - : allProviderAccounts)(); - const showProviderAccountPicker = shouldShowProviderAccountPicker(allProviderAccounts); - // A started thread that can fork lists every account; the ones it cannot switch to in place - // open in a new tab instead. - const providerAccounts = onForkModel ? allProviderAccounts : lockedProviderAccounts; - const resolveAccountSwitchModel = (instanceId: ProviderInstanceId) => - resolveModelForAccountSwitch({ - currentModel: selectedModelForPickerWithCustomFallback, - destinationModels: modelOptionsByInstance.get(instanceId) ?? [], - }); - const accountRequiresFork = (instanceId: ProviderInstanceId) => - onForkModel !== undefined && - (!lockedProviderAccounts.some((account) => account.instanceId === instanceId) || - modelRequiresFork?.(instanceId, resolveAccountSwitchModel(instanceId)) === true); - const onAccountChange = (instanceId: ProviderInstanceId) => { - const nextModel = resolveAccountSwitchModel(instanceId); - if (onForkModel && accountRequiresFork(instanceId)) { - onForkModel(instanceId, nextModel); - return; - } - setMultipleModelSelections(null); - onProviderModelSelect(instanceId, nextModel); - }; - const restingProviderAccountPicker = showProviderAccountPicker ? ( -
); } + +export function PullRequestChip(props: { + metadata: PullRequestContextMetadata; + environmentId: EnvironmentId | null; + label: string; + kindLabel: string; + kind: ContextChipKind; + copyMarkdown?: string; + onOpen: (event: MouseEvent, url: string) => void; +}) { + const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); + const displayState = + props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state; + const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon; + const button = ( + } + aria-label={`Open ${props.kindLabel} ${props.label}: ${props.metadata.title}`} + data-markdown-copy={props.copyMarkdown} + onClick={(event) => props.onOpen(event, props.metadata.url)} + onAuxClick={(event) => props.onOpen(event, props.metadata.url)} + > + + {props.label} + + ); + if (previewTarget !== null) { + return ( + } + /> + ); + } + return ( + + + + + + + ); +} diff --git a/apps/web/src/components/contextPresentationRegistry.test.ts b/apps/web/src/components/contextPresentationRegistry.test.ts index 2bbff10e1520..a3fe339a4ed9 100644 --- a/apps/web/src/components/contextPresentationRegistry.test.ts +++ b/apps/web/src/components/contextPresentationRegistry.test.ts @@ -10,7 +10,7 @@ import { ContextPreviewHover, createContextPresentationRegistry, type ContextPresentationHandlers, -} from "./contextPresentationRegistry"; +} from "./contextPreviewRegistry"; const fallback = (kind: string) => createElement("span", { "data-fallback": kind }); diff --git a/apps/web/src/components/contextPresentationRegistry.ts b/apps/web/src/components/contextPresentationRegistry.ts new file mode 100644 index 000000000000..561ed250ca3e --- /dev/null +++ b/apps/web/src/components/contextPresentationRegistry.ts @@ -0,0 +1,181 @@ +import { COMPOSER_CONTEXT_KINDS, type KnownComposerContextKind } from "@t3tools/contracts"; + +export interface ContextPresentationCapability { + details: "none" | "tooltip" | "popover"; + expanded: "none" | "inline-block" | "modal"; + defaultDraftView: "compact" | "expanded"; +} + +export interface ContextPresentationDefinition { + kind: KnownComposerContextKind; + capabilities: ContextPresentationCapability; +} + +const DEFINITIONS = [ + { + kind: "image", + capabilities: { details: "tooltip", expanded: "modal", defaultDraftView: "compact" }, + }, + { + kind: "file", + capabilities: { details: "tooltip", expanded: "modal", defaultDraftView: "compact" }, + }, + { + kind: "terminal", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "element", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "preview-annotation", + capabilities: { details: "popover", expanded: "modal", defaultDraftView: "compact" }, + }, + { + kind: "review-comment", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "mention", + capabilities: { details: "none", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "skill", + capabilities: { details: "tooltip", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "thread", + capabilities: { details: "tooltip", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "thread-tab", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "linear-issue", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "github-issue", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "repository", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "checkout", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "slack-thread", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "discord-thread", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "notion-page", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "granola-note", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "subagent", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "prompt-link", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "github-ref", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "unread-link", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, + { + kind: "model", + capabilities: { details: "popover", expanded: "none", defaultDraftView: "compact" }, + }, +] as const satisfies ReadonlyArray; + +function buildDefinitionRegistry( + definitions: ReadonlyArray, +): ReadonlyMap { + const registry = new Map(); + for (const definition of definitions) { + if (registry.has(definition.kind)) { + throw new Error(`Duplicate context presentation definition: ${definition.kind}`); + } + registry.set(definition.kind, definition); + } + for (const kind of COMPOSER_CONTEXT_KINDS) { + if (!registry.has(kind)) { + throw new Error(`Missing context presentation definition: ${kind}`); + } + } + return registry; +} + +const CONTEXT_PRESENTATION_DEFINITIONS = buildDefinitionRegistry(DEFINITIONS); + +function contextPresentationDefinition( + kind: KnownComposerContextKind, +): ContextPresentationDefinition { + return CONTEXT_PRESENTATION_DEFINITIONS.get(kind)!; +} + +export interface ContextPresentationHandler { + kind: KnownComposerContextKind; + canRender?: (record: TRecord, context: TRenderContext) => boolean; + render: ( + record: TRecord, + context: TRenderContext, + definition: ContextPresentationDefinition, + ) => TResult; +} + +export interface ContextPresentationRegistry { + definition(kind: KnownComposerContextKind): ContextPresentationDefinition; + render(kind: string, record: TRecord | undefined, context: TRenderContext): TResult; +} + +export function createContextPresentationRegistry(options: { + handlers: ReadonlyArray>; + requiredKinds?: ReadonlyArray; + fallback: (kind: string, record: TRecord | undefined, context: TRenderContext) => TResult; +}): ContextPresentationRegistry { + const handlers = new Map< + KnownComposerContextKind, + ContextPresentationHandler + >(); + for (const handler of options.handlers) { + if (handlers.has(handler.kind)) { + throw new Error(`Duplicate context presentation handler: ${handler.kind}`); + } + handlers.set(handler.kind, handler); + } + for (const kind of options.requiredKinds ?? []) { + if (!handlers.has(kind)) { + throw new Error(`Missing context presentation handler: ${kind}`); + } + } + + return { + definition: contextPresentationDefinition, + render(kind, record, context) { + const handler = handlers.get(kind as KnownComposerContextKind); + if (!handler || record === undefined || handler.canRender?.(record, context) === false) { + return options.fallback(kind, record, context); + } + return handler.render(record, context, contextPresentationDefinition(handler.kind)); + }, + }; +} diff --git a/apps/web/src/components/contextPresentationRegistry.tsx b/apps/web/src/components/contextPreviewRegistry.tsx similarity index 100% rename from apps/web/src/components/contextPresentationRegistry.tsx rename to apps/web/src/components/contextPreviewRegistry.tsx diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index 63c73d52d056..ee4ece96af2a 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -1,8 +1,8 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render, stubGlobal } from "../../../test/render"; const state = vi.hoisted(() => ({ projects: [] as never[], @@ -87,44 +87,43 @@ const pullRequestView = { }, }; -let root: Root; +function setup() { + vi.clearAllMocks(); + state.projects = []; + appAtomRegistry.set(discoverySettled, false); +} /** Mounts the coordinator behind a desktop bridge that hands over `urls` once the renderer is ready. */ async function mount(...urls: string[]) { let deliver: ((url: string) => void) | undefined; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("desktopBridge", { - getClientSettings: async () => null, - webLinks: { - onOpen: (listener: (url: string) => void) => { - deliver = listener; - return () => undefined; + await using stack = new AsyncDisposableStack(); + stack.use( + stubGlobal("desktopBridge", { + getClientSettings: async () => null, + webLinks: { + onOpen: (listener: (url: string) => void) => { + deliver = listener; + return () => undefined; + }, + setReady: async (ready: boolean) => { + if (ready) for (const url of urls.splice(0)) deliver?.(url); + }, }, - setReady: async (ready: boolean) => { - if (ready) for (const url of urls.splice(0)) deliver?.(url); - }, - }, - }); - root = createRoot(document.createElement("div")); - await act(async () => - root.render( + }), + ); + stack.use( + await render( , ), ); + return stack.move(); } -afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); - vi.clearAllMocks(); - state.projects = []; - appAtomRegistry.set(discoverySettled, false); -}); - it("holds a launch PR link until project discovery settles, then opens the PR view", async () => { - await mount(pullRequestUrl); + setup(); + await using _coordinator = await mount(pullRequestUrl); expect(state.navigate).not.toHaveBeenCalled(); expect(state.openScratchProject).not.toHaveBeenCalled(); @@ -139,9 +138,3 @@ it("holds a launch PR link until project discovery settles, then opens the PR vi expect(state.navigate).toHaveBeenCalledExactlyOnceWith(pullRequestView); expect(state.openScratchProject).not.toHaveBeenCalled(); }); - -it("opens an ordinary link in the browser while a PR link is still held", async () => { - await mount(pullRequestUrl, "https://example.com/docs"); - expect(state.openScratchProject).toHaveBeenCalledOnce(); - expect(state.navigate).not.toHaveBeenCalled(); -}); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts index b38e2f7018df..4e29d1ebc363 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts +++ b/apps/web/src/components/desktop/SnapShotCoordinator.browser.test.ts @@ -1,24 +1,9 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - type ClientSettings, - type DesktopPendingSnapShot, - type DesktopSnapShotEvent, - DEFAULT_CLIENT_SETTINGS, - EnvironmentId, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - ProjectId, - ThreadId, -} from "@t3tools/contracts"; -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import type { DesktopSnapShotBridge } from "../../lib/desktopSnapShot"; -import * as settings from "../../hooks/useSettings"; -import * as newThread from "../../hooks/useHandleNewThread"; -import * as snapShotSound from "../../lib/snapShotSound"; -import { toastManager } from "../ui/toast"; import * as imageCompression from "../../lib/imageCompression"; import { questionAttachmentDraftId, @@ -30,15 +15,12 @@ import { dismissFailedSnapShot, resolveExistingSnapShotTarget, resolveSnapShotAttachmentTarget, - resolveSnapShotTargetOnce, resolveSnapShotDeliveryTarget, - SnapShotCoordinator, } from "./SnapShotCoordinator"; import { beginSnapShotAnimation, dismissAllSnapShotAnimations, getPendingSnapShotAnimations, - setSnapShotAnimationDestination, scheduleSnapShotAnimationDestination, } from "../../lib/snapShotAnimation"; @@ -66,7 +48,8 @@ const storage = vi.hoisted(() => { const environmentId = EnvironmentId.make("snap-shot-environment"); const projectRef = scopeProjectRef(environmentId, ProjectId.make("snap-shot-project")); -beforeEach(() => { +// Tests stub globals and spy on module exports freely; disposing restores all of them. +function setup(): Disposable { storage.clear(); vi.stubGlobal("localStorage", storage); useComposerDraftStore.setState({ @@ -76,43 +59,18 @@ beforeEach(() => { stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); -}); - -afterEach(() => { - dismissAllSnapShotAnimations(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); + return { + [Symbol.dispose]() { + dismissAllSnapShotAnimations(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }, + }; +} describe("window capture failures", () => { - it("dismisses an older failed capture without disturbing a newer capture", async () => { - const target = DraftId.make("snap-shot-draft"); - const pendingStarts = new Set(); - const soundedIds = new Set(["older", "newer"]); - const dismissSnapShotAnimation = vi.fn(async () => undefined); - vi.stubGlobal("desktopBridge", { - requestSnapShotPermissions: vi.fn(), - getSnapShotState: vi.fn(), - checkSnapShotShortcut: vi.fn(), - setSnapShotShortcutSuppressed: vi.fn(), - listPendingSnapShots: vi.fn(), - readSnapShot: vi.fn(), - acknowledgeSnapShot: vi.fn(), - onSnapShotEvent: vi.fn(() => () => undefined), - dismissSnapShotAnimation, - }); - await beginSnapShotAnimationWhenReady("older", Promise.resolve(target), pendingStarts); - await beginSnapShotAnimationWhenReady("newer", Promise.resolve(target), pendingStarts); - - dismissFailedSnapShot("older", soundedIds, pendingStarts); - - expect(getPendingSnapShotAnimations().map(({ id }) => id)).toEqual(["newer"]); - expect(soundedIds).toEqual(new Set(["newer"])); - expect(dismissSnapShotAnimation).toHaveBeenCalledExactlyOnceWith("older"); - }); - it("does not resurrect a failed capture when its draft becomes ready later", async () => { + using _env = setup(); const target = DraftId.make("snap-shot-draft"); let resolveTarget: ((target: DraftId) => void) | undefined; const targetReady = new Promise((resolve) => { @@ -131,470 +89,11 @@ describe("window capture failures", () => { expect(soundedIds).toEqual(new Set(["newer"])); expect(pendingStarts.size).toBe(0); }); - - it("keeps global failures dismissing all active and pending captures", async () => { - const target = DraftId.make("snap-shot-draft"); - let resolveTarget: ((target: DraftId) => void) | undefined; - const targetReady = new Promise((resolve) => { - resolveTarget = resolve; - }); - const pendingStarts = new Set(); - const soundedIds = new Set(["older", "newer"]); - await beginSnapShotAnimationWhenReady("older", Promise.resolve(target), pendingStarts); - const newerStart = beginSnapShotAnimationWhenReady("newer", targetReady, pendingStarts); - - dismissFailedSnapShot(undefined, soundedIds, pendingStarts); - resolveTarget?.(target); - await newerStart; - - expect(getPendingSnapShotAnimations()).toEqual([]); - expect(soundedIds.size).toBe(0); - expect(pendingStarts.size).toBe(0); - }); -}); - -describe("pending snapshot processing", () => { - const capture = { - id: "12345678-1234-1234-1234-123456789abc", - name: "window.png", - mimeType: "image/png" as const, - sizeBytes: 3, - dataUrl: "data:image/png;base64,AQID", - source: { - kind: "snap-shot" as const, - capturedAt: "2026-09-01T00:00:00.000Z", - appName: "Editor", - windowTitle: "main.ts", - }, - }; - const target = scopeThreadRef(environmentId, ThreadId.make("enabled-thread")); - let root: Root; - let container: HTMLDivElement; - - function setup(routeThreadRef: typeof target | null = target) { - let currentSettings = { - ...DEFAULT_CLIENT_SETTINGS, - snapShotEnabled: true, - snapShotPlaySound: true, - snapShotAnimations: false, - }; - const listeners = new Set<(event: DesktopSnapShotEvent) => void>(); - let pending = [capture]; - const bridge = { - requestSnapShotPermissions: vi.fn(), - getSnapShotState: vi.fn(), - checkSnapShotShortcut: vi.fn(), - setSnapShotShortcutSuppressed: vi.fn(), - onSnapShotEvent: vi.fn((listener: (event: DesktopSnapShotEvent) => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }), - listPendingSnapShots: vi.fn(async () => pending), - readSnapShot: vi.fn(async () => capture), - acknowledgeSnapShot: vi.fn(async (id: string) => { - pending = pending.filter((item) => item.id !== id); - }), - }; - vi.spyOn(settings, "getClientSettings").mockImplementation(() => currentSettings); - vi.spyOn(settings, "useClientSettings").mockImplementation( - (selector?: (value: ClientSettings) => T) => - selector ? selector(currentSettings) : (currentSettings as T), - ); - const route = { - activeDraftThread: null, - activeThread: null, - defaultProjectRef: routeThreadRef ? projectRef : null, - handleNewThread: vi.fn["handleNewThread"]>(), - routeDraftId: null, - routeThreadRef, - }; - vi.spyOn(newThread, "useHandleNewThread").mockImplementation(() => ({ ...route })); - const sound = vi.spyOn(snapShotSound, "playSnapShotSound").mockImplementation(() => undefined); - const toast = vi.spyOn(toastManager, "add").mockReturnValue("toast-id"); - vi.stubGlobal("desktopBridge", bridge); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - const render = () => act(async () => root.render(createElement(SnapShotCoordinator))); - return { - bridge, - route, - sound, - toast, - render, - setPending: (items: typeof pending) => { - pending = items; - }, - setEnabled: async (snapShotEnabled: boolean) => { - currentSettings = { ...currentSettings, snapShotEnabled }; - await render(); - }, - emit: (event: DesktopSnapShotEvent) => - act(async () => { - for (const listener of listeners) listener(event); - }), - }; - } - - afterEach(async () => { - await act(async () => root?.unmount()); - container?.remove(); - }); - - const focus = () => - act(async () => { - window.dispatchEvent(new Event("focus")); - }); - - it("leaves captures untouched while disabled and resumes them when enabled again", async () => { - const fixture = setup(); - const { bridge, sound, toast } = fixture; - await fixture.setEnabled(false); - await focus(); - await act(async () => { - document.dispatchEvent(new Event("visibilitychange")); - }); - expect(bridge.onSnapShotEvent).not.toHaveBeenCalled(); - expect(bridge.listPendingSnapShots).not.toHaveBeenCalled(); - expect(bridge.readSnapShot).not.toHaveBeenCalled(); - expect(sound).not.toHaveBeenCalled(); - expect(toast).not.toHaveBeenCalled(); - - await fixture.setEnabled(true); - await vi.waitFor(() => - expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), - ); - expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); - expect(sound).toHaveBeenCalledOnce(); - - await fixture.setEnabled(false); - const another = { ...capture, id: "another-capture" }; - fixture.setPending([another]); - bridge.listPendingSnapShots.mockClear(); - await fixture.emit({ type: "started", id: another.id }); - await fixture.emit({ type: "ready", id: another.id }); - await fixture.emit({ type: "failed", id: another.id }); - await focus(); - await act(async () => { - document.dispatchEvent(new Event("visibilitychange")); - }); - expect(bridge.listPendingSnapShots).not.toHaveBeenCalled(); - expect(bridge.getSnapShotState).not.toHaveBeenCalled(); - expect(bridge.readSnapShot).toHaveBeenCalledOnce(); - expect(sound).toHaveBeenCalledOnce(); - expect(toast).not.toHaveBeenCalled(); - }); - - it("reports each pending capture failure once, allows a new failure kind, and resets after success", async () => { - const fixture = setup(null); - const { bridge, sound, toast, route } = fixture; - await fixture.render(); - await focus(); - expect(toast).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - title: "Snapshot taken, but no project is available", - }), - ); - expect(sound).toHaveBeenCalledOnce(); - expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); - - route.routeThreadRef = target; - bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable")); - await fixture.render(); - await focus(); - await fixture.emit({ type: "ready", id: capture.id }); - expect(toast.mock.calls.map(([value]) => value.title)).toEqual([ - "Snapshot taken, but no project is available", - "Snapshot failed", - ]); - expect(sound).toHaveBeenCalledOnce(); - expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); - - route.routeThreadRef = null; - await fixture.render(); - await focus(); - expect(toast).toHaveBeenCalledTimes(2); - route.routeThreadRef = target; - await fixture.render(); - - bridge.readSnapShot.mockResolvedValue(capture); - await focus(); - await vi.waitFor(() => - expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), - ); - expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); - fixture.setPending([capture]); - bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable again")); - await focus(); - await focus(); - expect(sound).toHaveBeenCalledTimes(2); - expect(toast.mock.calls.map(([value]) => value.title)).toEqual([ - "Snapshot taken, but no project is available", - "Snapshot failed", - "Snapshot failed", - ]); - }); - - it("suppresses deferred failures while disabled and reports fresh failures after re-enable", async () => { - const fixture = setup(); - const { bridge, sound, toast } = fixture; - let rejectRead: ((error: Error) => void) | undefined; - bridge.readSnapShot.mockReturnValueOnce( - new Promise((_resolve, reject) => { - rejectRead = reject; - }), - ); - await fixture.render(); - expect(bridge.readSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); - await fixture.setEnabled(false); - await act(async () => rejectRead?.(new Error("Capture unavailable"))); - expect(toast).not.toHaveBeenCalled(); - expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); - - bridge.readSnapShot.mockRejectedValue(new Error("Capture unavailable")); - await fixture.setEnabled(true); - await focus(); - expect(toast).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ description: `Capture ${capture.id}: Capture unavailable` }), - ); - expect(sound).toHaveBeenCalledOnce(); - - await fixture.setEnabled(false); - let rejectList: ((error: Error) => void) | undefined; - bridge.listPendingSnapShots.mockReturnValueOnce( - new Promise((_resolve, reject) => { - rejectList = reject; - }), - ); - await fixture.setEnabled(true); - await fixture.setEnabled(false); - await act(async () => rejectList?.(new Error("Pending captures unavailable"))); - expect(toast).toHaveBeenCalledOnce(); - - bridge.listPendingSnapShots.mockRejectedValueOnce(new Error("Pending captures unavailable")); - await fixture.setEnabled(true); - expect(toast).toHaveBeenCalledTimes(2); - expect(toast).toHaveBeenLastCalledWith( - expect.objectContaining({ description: "Pending captures unavailable" }), - ); - - fixture.setPending([]); - let resolveState: ((state: { message: string }) => void) | undefined; - bridge.getSnapShotState.mockReturnValueOnce( - new Promise<{ message: string }>((resolve) => { - resolveState = resolve; - }), - ); - await fixture.emit({ type: "failed", id: "native-capture" }); - expect(bridge.getSnapShotState).toHaveBeenCalledOnce(); - await fixture.setEnabled(false); - await act(async () => resolveState?.({ message: "Native capture unavailable" })); - expect(toast).toHaveBeenCalledTimes(2); - - bridge.getSnapShotState.mockResolvedValue({ message: "Native capture unavailable" }); - await fixture.setEnabled(true); - await fixture.emit({ type: "failed", id: "native-capture" }); - expect(toast).toHaveBeenCalledTimes(3); - expect(toast).toHaveBeenLastCalledWith( - expect.objectContaining({ description: "Native capture unavailable" }), - ); - expect(sound).toHaveBeenCalledOnce(); - expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); - }); - - it("keeps a capture pending when disabled during target resolution, then retries on re-enable", async () => { - const fixture = setup(null); - const { bridge, route } = fixture; - route.defaultProjectRef = projectRef; - const draftId = DraftId.make("resolving-snapshot"); - const threadId = ThreadId.make("resolving-snapshot-thread"); - let resolveTarget: ((target: { draftId: DraftId; threadId: ThreadId }) => void) | undefined; - const resolution = new Promise<{ draftId: DraftId; threadId: ThreadId }>((resolve) => { - resolveTarget = resolve; - }); - route.handleNewThread.mockReturnValue(resolution); - await fixture.render(); - expect(route.handleNewThread).toHaveBeenCalledOnce(); - await fixture.setEnabled(false); - await act(async () => { - useComposerDraftStore.getState().setProjectDraftThreadId(projectRef, draftId, { threadId }); - resolveTarget?.({ draftId, threadId }); - }); - expect(bridge.readSnapShot).not.toHaveBeenCalled(); - expect(bridge.acknowledgeSnapShot).not.toHaveBeenCalled(); - expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.images ?? []).toEqual([]); - await fixture.setEnabled(true); - await vi.waitFor(() => - expect(bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), - ); - expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.images).toHaveLength(1); - }); - - it("finishes and acknowledges delivery already reading when disabled", async () => { - const fixture = setup(); - let finishReading: ((value: typeof capture) => void) | undefined; - const reading = new Promise((resolve) => { - finishReading = resolve; - }); - fixture.bridge.readSnapShot.mockReturnValue(reading); - await fixture.render(); - expect(fixture.bridge.readSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id); - await fixture.setEnabled(false); - await act(async () => { - finishReading?.(capture); - }); - await vi.waitFor(() => - expect(fixture.bridge.acknowledgeSnapShot).toHaveBeenCalledExactlyOnceWith(capture.id), - ); - expect(useComposerDraftStore.getState().getComposerDraft(target)?.images).toHaveLength(1); - expect(fixture.toast).not.toHaveBeenCalled(); - }); -}); - -describe("window capture delivery", () => { - it.each([ - { target: DraftId.make("snap-shot-draft"), accessibleText: undefined }, - { target: DraftId.make("snap-shot-draft"), accessibleText: "const answer = 42;" }, - { - target: scopeThreadRef(environmentId, ThreadId.make("snap-shot-thread")), - accessibleText: undefined, - }, - { - target: scopeThreadRef(environmentId, ThreadId.make("snap-shot-thread")), - accessibleText: "const answer = 42;", - }, - ])( - "preserves capture contents for $target before a stalled animation finishes ($accessibleText)", - async ({ target, accessibleText }) => { - vi.useFakeTimers(); - const never = new Promise(() => undefined); - const animationFrames: Array = []; - const acknowledgeSnapShot = vi.fn(async () => undefined); - const bridge = { - requestSnapShotPermissions: vi.fn(async () => undefined), - getSnapShotState: vi.fn(), - checkSnapShotShortcut: vi.fn(), - setSnapShotShortcutSuppressed: vi.fn(async () => undefined), - listPendingSnapShots: vi.fn(async () => []), - readSnapShot: vi.fn(async () => ({ - id: "12345678-1234-1234-1234-123456789abc", - name: "window.png", - mimeType: "image/png", - sizeBytes: 3, - dataUrl: "data:image/png;base64,AQID", - source: { - kind: "snap-shot" as const, - capturedAt: "2026-09-01T00:00:00.000Z", - appName: "Editor", - windowTitle: "main.ts", - ...(accessibleText ? { accessibleText } : {}), - }, - })), - acknowledgeSnapShot, - setSnapShotAnimationDestination: vi.fn(() => never), - onMenuAction: vi.fn(() => () => undefined), - onSnapShotEvent: vi.fn(() => () => undefined), - } as unknown as DesktopSnapShotBridge; - const item: DesktopPendingSnapShot = { - id: "12345678-1234-1234-1234-123456789abc", - name: "window.png", - mimeType: "image/png", - sizeBytes: 3, - source: { - kind: "snap-shot", - capturedAt: "2026-09-01T00:00:00.000Z", - appName: "Editor", - windowTitle: "main.ts", - ...(accessibleText ? { accessibleText } : {}), - }, - }; - vi.stubGlobal("desktopBridge", bridge); - vi.spyOn(window, "matchMedia").mockReturnValue({ matches: false } as MediaQueryList); - vi.spyOn(window, "getComputedStyle").mockReturnValue({ - backgroundColor: "rgb(0, 0, 0)", - borderTopColor: "rgb(255, 255, 255)", - borderTopLeftRadius: "8px", - borderTopWidth: "1px", - } as CSSStyleDeclaration); - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - animationFrames.push(callback); - return animationFrames.length; - }); - - if (typeof target === "string") { - useComposerDraftStore.getState().setProjectDraftThreadId(projectRef, target, { - threadId: ThreadId.make("snap-shot-draft-thread"), - }); - } - beginSnapShotAnimation(item.id, target); - setSnapShotAnimationDestination( - item.id, - { - isConnected: true, - getBoundingClientRect: () => ({ x: 0, y: 0, width: 208, height: 112 }), - } as HTMLElement, - item.source, - ); - - const persisted = new Promise((resolve) => { - const unsubscribe = useComposerDraftStore.subscribe((state) => { - if (state.getComposerDraft(target)?.persistedAttachments.length) { - unsubscribe(); - resolve(); - } - }); - }); - const delivery = deliverSnapShot(bridge, item, () => target); - await persisted; - await vi.advanceTimersByTimeAsync(0); - - const draft = useComposerDraftStore.getState().getComposerDraft(target); - expect(draft?.images).toHaveLength(1); - expect(draft?.images[0]?.source).toEqual(item.source); - expect(getPendingSnapShotAnimations()).toHaveLength(1); - expect(acknowledgeSnapShot).not.toHaveBeenCalled(); - - expect(animationFrames).toHaveLength(1); - animationFrames.shift()?.(0); - animationFrames.shift()?.(0); - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(2_000); - expect(getPendingSnapShotAnimations()).toHaveLength(0); - expect(acknowledgeSnapShot).not.toHaveBeenCalled(); - expect(animationFrames).toHaveLength(1); - animationFrames.shift()?.(0); - expect(acknowledgeSnapShot).not.toHaveBeenCalled(); - animationFrames.shift()?.(0); - await delivery; - expect(acknowledgeSnapShot).toHaveBeenCalledWith(item.id); - }, - ); }); describe("window capture target resolution", () => { - it("shares bare-route draft creation between animation start and capture drain", async () => { - const draftId = DraftId.make("snap-shot-draft"); - let finishResolution: ((target: DraftId) => void) | undefined; - const resolveTarget = vi.fn( - () => - new Promise((resolve) => { - finishResolution = resolve; - }), - ); - const resolutionRef: { current: Promise | null } = { current: null }; - - const animationTarget = resolveSnapShotTargetOnce(resolutionRef, resolveTarget); - const attachmentTarget = resolveSnapShotTargetOnce(resolutionRef, resolveTarget); - - expect(animationTarget).toBe(attachmentTarget); - expect(resolveTarget).toHaveBeenCalledTimes(1); - finishResolution?.(draftId); - await expect(animationTarget).resolves.toBe(draftId); - expect(resolutionRef.current).toBeNull(); - }); - it("follows a draft to its promoted server thread", () => { + using _env = setup(); const draftId = DraftId.make("snap-shot-draft"); const promotedRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-promoted-thread")); const store = useComposerDraftStore.getState(); @@ -605,12 +104,6 @@ describe("window capture target resolution", () => { expect(resolveExistingSnapShotTarget(draftId, null)).toEqual(promotedRef); }); - - it("keeps the routed server thread before its shell loads", () => { - const routeThreadRef = scopeThreadRef(environmentId, ThreadId.make("snap-shot-routed-thread")); - - expect(resolveExistingSnapShotTarget(routeThreadRef, routeThreadRef)).toEqual(routeThreadRef); - }); }); describe("open question delivery", () => { @@ -638,6 +131,7 @@ describe("open question delivery", () => { }; it("attaches a capture to the open question draft and leaves the thread draft alone", async () => { + using _env = setup(); const pins = new Map(); const untrack = trackOpenQuestionAttachmentDraft(threadRef, request); try { @@ -655,6 +149,7 @@ describe("open question delivery", () => { }); it("falls back to the thread when a newer question replaces the pinned one during compression", async () => { + using _env = setup(); const pins = new Map(); const untrackFirst = trackOpenQuestionAttachmentDraft(threadRef, request); let untrackSecond = () => {}; @@ -686,6 +181,7 @@ describe("open question delivery", () => { }); it("keeps a capture pinned before any question off a question that opens during the read", async () => { + using _env = setup(); const pins = new Map(); const resolveTarget = () => resolveSnapShotAttachmentTarget(pins, capture.id, threadRef); // The drain pins a capture it never saw requested before reading it. @@ -705,40 +201,13 @@ describe("open question delivery", () => { untrack(); } }); - - it("refuses a capture once the request's other questions hold the shared limit", async () => { - useComposerDraftStore.getState().addImages( - first, - Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS }, (_, index) => ({ - type: "image" as const, - id: `staged-${index}`, - name: `staged-${index}.png`, - mimeType: "image/png", - sizeBytes: 3, - previewUrl: `data:image/png;base64,AQID${index}`, - file: new File([new Uint8Array([1, 2, 3])], `staged-${index}.png`, { type: "image/png" }), - })), - ); - const untrack = trackOpenQuestionAttachmentDraft(threadRef, { ...request, draftId: second }); - try { - const { bridge, acknowledgeSnapShot } = makeBridge(); - await expect(deliverSnapShot(bridge, capture, () => second)).rejects.toThrow( - "Remove an attachment", - ); - expect(useComposerDraftStore.getState().getComposerDraft(second)?.images ?? []).toHaveLength( - 0, - ); - expect(acknowledgeSnapShot).not.toHaveBeenCalled(); - } finally { - untrack(); - } - }); }); describe("durable snapshot delivery", () => { it.each([false, true])( "retains a capture on quota failure and retries without duplicates (staged: %s)", async (staged) => { + using _env = setup(); const target = scopeThreadRef(environmentId, ThreadId.make("quota-thread")); const capture = { id: "12345678-1234-1234-1234-123456789abc", @@ -798,6 +267,7 @@ describe("snapshot destination ownership", () => { it.each(["unmount", "blur", "disabled animations"])( "keeps the original environment and thread after %s", async (reason) => { + using _env = setup(); const original = scopeThreadRef(environmentId, ThreadId.make("original")); const next = scopeThreadRef(EnvironmentId.make("another-environment"), ThreadId.make("next")); const targets = new Map>(); diff --git a/apps/web/src/components/desktop/SnapShotCoordinator.tsx b/apps/web/src/components/desktop/SnapShotCoordinator.tsx index 503572ba86eb..9dfdad33fb6d 100644 --- a/apps/web/src/components/desktop/SnapShotCoordinator.tsx +++ b/apps/web/src/components/desktop/SnapShotCoordinator.tsx @@ -116,7 +116,7 @@ export function dismissFailedSnapShot( } } -export function resolveSnapShotTargetOnce( +function resolveSnapShotTargetOnce( resolutionRef: { current: Promise | null }, resolveTarget: () => Promise, ): Promise { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 57184d641a79..62a5209bd5e0 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -1,17 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; -import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopUpdateState } from "@t3tools/contracts"; import { canCheckForUpdate, getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, - getDesktopUpdateDisabledReason, - getDesktopUpdateIdleTooltip, getDesktopUpdateInstallConfirmationMessage, getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, - isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldToastDesktopUpdateActionResult, @@ -37,15 +33,6 @@ const baseState: DesktopUpdateState = { }; describe("desktop update button state", () => { - it("shows a download action when an update is available", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "available", - availableVersion: "1.1.0", - }; - expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); - }); - it("keeps retry action available after a download error", () => { const state: DesktopUpdateState = { ...baseState, @@ -59,54 +46,6 @@ describe("desktop update button state", () => { expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); - it("says when the same version did not apply before", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "downloaded", - availableVersion: "1.1.0", - downloadedVersion: "1.1.0", - notAppliedVersion: "1.1.0", - }; - expect(getDesktopUpdateButtonTooltip(state)).toBe( - "Update 1.1.0 did not apply. Click to retry.", - ); - expect( - getDesktopUpdateButtonTooltip({ - ...state, - availableVersion: "1.2.0", - downloadedVersion: "1.2.0", - }), - ).toContain("1.2.0 downloaded"); - }); - - it("keeps install action available after an install error", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "error", - downloadedVersion: "1.1.0", - availableVersion: "1.1.0", - message: "shutdown timeout", - errorContext: "install", - canRetry: true, - }; - expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); - expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); - }); - - it("keeps install action available after a background updater error", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "error", - downloadedVersion: "1.1.0", - availableVersion: "1.1.0", - message: "background updater error", - errorContext: null, - canRetry: true, - }; - expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); - expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); - }); - it("prefers a newly available release over a stale downloaded version", () => { const state: DesktopUpdateState = { ...baseState, @@ -127,96 +66,9 @@ describe("desktop update button state", () => { }; expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); - - it("has no action for non-actionable check errors", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "error", - message: "network unavailable", - errorContext: "check", - canRetry: true, - }; - expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); - }); - - it("disables the button while downloading", () => { - const state: DesktopUpdateState = { - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - downloadPercent: 42.5, - }; - expect(isDesktopUpdateButtonDisabled(state)).toBe(true); - expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); - }); -}); - -describe("getDesktopUpdateActionError", () => { - it("returns user-visible message for accepted failed attempts", () => { - const result: DesktopUpdateActionResult = { - accepted: true, - completed: false, - state: { - ...baseState, - status: "available", - availableVersion: "1.1.0", - message: "checksum mismatch", - errorContext: "download", - canRetry: true, - }, - }; - expect(getDesktopUpdateActionError(result)).toBe("checksum mismatch"); - }); - - it("ignores messages for non-accepted attempts", () => { - const result: DesktopUpdateActionResult = { - accepted: false, - completed: false, - state: { - ...baseState, - status: "error", - message: "background failure", - errorContext: "check", - canRetry: false, - }, - }; - expect(getDesktopUpdateActionError(result)).toBeNull(); - }); - - it("ignores messages for successful attempts", () => { - const result: DesktopUpdateActionResult = { - accepted: true, - completed: true, - state: { - ...baseState, - status: "downloaded", - downloadedVersion: "1.1.0", - availableVersion: "1.1.0", - message: null, - errorContext: null, - canRetry: true, - }, - }; - expect(getDesktopUpdateActionError(result)).toBeNull(); - }); }); describe("desktop update UI helpers", () => { - it("builds the stable release URL for a downloaded version", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30", "pingdotgg/t3code")).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", - ); - }); - - it("uses the configured desktop update repository for release URLs", () => { - expect(getDesktopUpdateReleaseUrl("1.2.3", "JustYannicc/t3code")).toBe( - "https://github.com/JustYannicc/t3code/releases/tag/v1.2.3", - ); - expect(getDesktopUpdateReleaseHistoryUrl("JustYannicc/t3code")).toBe( - "https://github.com/JustYannicc/t3code/releases", - ); - }); - it.each([ "owner/repo/extra", "owner#notes/repo", @@ -330,24 +182,6 @@ describe("desktop update UI helpers", () => { }); describe("canCheckForUpdate", () => { - it("returns false for null state", () => { - expect(canCheckForUpdate(null)).toBe(false); - }); - - it("returns false when updates are disabled", () => { - expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); - }); - - it("returns false while checking", () => { - expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); - }); - - it("returns false while downloading", () => { - expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( - false, - ); - }); - it("returns true once an update has been downloaded so newer releases can be found", () => { expect( canCheckForUpdate({ @@ -358,69 +192,4 @@ describe("canCheckForUpdate", () => { }), ).toBe(true); }); - - it("returns true when idle", () => { - expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); - }); - - it("returns true when up-to-date", () => { - expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); - }); - - it("returns true when an update is available", () => { - expect( - canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), - ).toBe(true); - }); - - it("returns true on error so the user can retry", () => { - expect( - canCheckForUpdate({ - ...baseState, - status: "error", - errorContext: "check", - message: "network", - }), - ).toBe(true); - }); -}); - -describe("getDesktopUpdateButtonTooltip", () => { - it("returns 'Up to date' for non-actionable states", () => { - expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); - expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( - "Up to date", - ); - }); -}); - -describe("disabled update reason", () => { - const disabledState: DesktopUpdateState = { - ...baseState, - enabled: false, - status: "disabled", - message: "Automatic updates are only available in packaged production builds.", - }; - - it("explains why checking is unavailable", () => { - expect(getDesktopUpdateDisabledReason(disabledState)).toBe( - "Automatic updates are only available in packaged production builds.", - ); - expect(getDesktopUpdateIdleTooltip(disabledState)).toEqual({ - title: "Updates unavailable", - detail: "Automatic updates are only available in packaged production builds.", - }); - expect(getDesktopUpdateDisabledReason({ ...disabledState, message: null })).toBe( - "Automatic updates are not available in this build.", - ); - }); - - it("keeps the check prompt when updates are available", () => { - expect(getDesktopUpdateDisabledReason(baseState)).toBeNull(); - expect(getDesktopUpdateDisabledReason(null)).toBeNull(); - expect(getDesktopUpdateIdleTooltip(baseState)).toEqual({ - title: "Check for updates", - detail: null, - }); - }); }); diff --git a/apps/web/src/components/desktopUpdate.toast.test.tsx b/apps/web/src/components/desktopUpdate.toast.test.tsx deleted file mode 100644 index af95240e7f90..000000000000 --- a/apps/web/src/components/desktopUpdate.toast.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -const testState = vi.hoisted(() => ({ - addToast: vi.fn(), -})); - -vi.mock("./ui/toast", () => ({ - toastManager: { add: testState.addToast }, -})); - -import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; - -type ClickableElement = ReactElement<{ readonly onClick?: () => void }>; - -function findReleaseNotesLink(node: ReactNode): ClickableElement | null { - if (Array.isArray(node)) { - for (const child of node) { - const found = findReleaseNotesLink(child); - if (found) return found; - } - return null; - } - if (!isValidElement(node)) return null; - const element = node as ReactElement<{ readonly children?: ReactNode }>; - if (element.type === "button") return element as ClickableElement; - if (typeof element.type === "function") { - const render = element.type as (props: unknown) => ReactNode; - return findReleaseNotesLink(render(element.props)); - } - return findReleaseNotesLink(element.props.children); -} - -function getDescription(): ReactNode { - const toast = testState.addToast.mock.calls[0]?.[0] as { description?: ReactNode } | undefined; - return toast?.description ?? null; -} - -function downloadedState(overrides: Partial = {}): DesktopUpdateState { - return { - enabled: true, - status: "downloaded", - channel: "latest", - currentVersion: "0.0.29", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: "0.0.30", - downloadedVersion: "0.0.30", - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: 100, - checkedAt: null, - message: null, - errorContext: null, - canRetry: true, - ...overrides, - }; -} - -describe("showDesktopUpdateDownloadedToast", () => { - beforeEach(() => { - testState.addToast.mockReset(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("opens release notes from the configured repository", async () => { - vi.stubEnv("T3CODE_DESKTOP_UPDATE_REPOSITORY", "JustYannicc/t3code"); - const openExternal = vi.fn().mockResolvedValue(true); - - showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); - findReleaseNotesLink(getDescription())?.props.onClick?.(); - - await vi.waitFor(() => { - expect(openExternal).toHaveBeenCalledWith( - "https://github.com/JustYannicc/t3code/releases/tag/v0.0.30", - ); - }); - }); - - it("opens the downloaded version's release notes", async () => { - const openExternal = vi.fn().mockResolvedValue(true); - - showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); - const link = findReleaseNotesLink(getDescription()); - link?.props.onClick?.(); - await vi.waitFor(() => { - expect(openExternal).toHaveBeenCalledWith( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", - ); - }); - expect(testState.addToast).toHaveBeenCalledTimes(1); - }); - - it("falls back to the version the download was started for", async () => { - const openExternal = vi.fn().mockResolvedValue(true); - - // The `update-downloaded` event can land after the download RPC resolves. - showDesktopUpdateDownloadedToast( - { openExternal }, - downloadedState({ downloadedVersion: null }), - ); - findReleaseNotesLink(getDescription())?.props.onClick?.(); - - await vi.waitFor(() => { - expect(openExternal).toHaveBeenCalledWith( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", - ); - }); - }); - - it("omits the link when the updater reports no version at all", () => { - showDesktopUpdateDownloadedToast( - { openExternal: vi.fn() }, - downloadedState({ availableVersion: null, downloadedVersion: null }), - ); - - expect(findReleaseNotesLink(getDescription())).toBeNull(); - }); - - it.each([ - ["returns false", vi.fn().mockResolvedValue(false)], - ["rejects", vi.fn().mockRejectedValue(new Error("open failed"))], - ])("shows an error when opening release notes %s", async (_description, openExternal) => { - showDesktopUpdateDownloadedToast({ openExternal }, downloadedState()); - findReleaseNotesLink(getDescription())?.props.onClick?.(); - - await vi.waitFor(() => { - expect(testState.addToast).toHaveBeenLastCalledWith({ - type: "error", - title: "Unable to open release notes", - }); - }); - }); -}); diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index a6aaef3dd97a..c98abb4e3b41 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -4,12 +4,13 @@ import { type DeviceServiceState, type ScopedThreadRef, } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { RightPanelSurface } from "~/rightPanelStore"; +import { act, render } from "../../../test/render"; + import { DevicePanel } from "./DevicePanel"; import "~/index.css"; @@ -103,13 +104,7 @@ const switches = [ ], ] as const; const threadB = switches[0][1]; -const otherEnvironment = switches[1][1]; const picker: Extract = { id: "device", kind: "device" }; -const streaming: Extract = { - id: "device:phone", - kind: "device", - target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, -}; const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }; const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; const failed = { _tag: "Failure", cause: new Error("boom") } as const; @@ -120,22 +115,18 @@ const deferred = () => { return { promise, settle }; }; -let root: Root; let container: HTMLDivElement; // Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it. -const show = (threadRef: ScopedThreadRef, surface = picker) => - act(async () => - root.render( - undefined} - />, - ), - ); +const devicePanel = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined} + /> +); const startButton = () => container.querySelector('[aria-label="Start Phone"]'); const click = (button: HTMLButtonElement | null) => act(async () => { @@ -143,143 +134,17 @@ const click = (button: HTMLButtonElement | null) => }); const errors = () => container.querySelectorAll('[role="alert"]'); -beforeEach(() => { +function setup() { vi.resetAllMocks(); currentState = deviceState; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); container.style.width = "480px"; container.style.height = "800px"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -describe("DevicePanel notices", () => { - it("keeps discovery warnings dismissed until the diagnostic or environment changes", async () => { - currentState = { - ...deviceState, - hostStatusDetail: "Could not read simulator usage timestamps.", - }; - await show(threadA); - expect(errors()).toHaveLength(1); - expect(startButton()?.disabled).toBe(false); - await click(container.querySelector('[aria-label="Dismiss device warning"]')); - expect(errors()).toHaveLength(0); - await show(threadA); - expect(errors()).toHaveLength(0); - - currentState = { ...currentState, hostStatusDetail: "A different discovery warning." }; - await show(threadA); - expect(errors()).toHaveLength(1); - await click(container.querySelector('[aria-label="Dismiss device warning"]')); - await show(otherEnvironment); - expect(errors()).toHaveLength(1); - }); - - it("keeps the loading content centered when discovery and boot notices arrive or are dismissed", async () => { - const pick = deferred(); - mocks.open.mockReturnValue(pick.promise); - await show(threadA); - await click(startButton()); - const loadingBounds = () => - container - .querySelector('[aria-label="Step 1 of 2: open device"]')! - .parentElement!.getBoundingClientRect(); - const before = loadingBounds(); - const panel = container.getBoundingClientRect(); - expect(before.height).toBeGreaterThan(0); - expect(before.top + before.height / 2).toBeCloseTo(panel.top + panel.height / 2, 0); - - currentState = { - ...deviceState, - hostStatusDetail: "Could not read CoreSimulator plist.\n".repeat(8), - bootingDevices: [{ ...phone, threadId: threadA.threadId }], - }; - await show(threadA); - expect(loadingBounds().top).toBe(before.top); - expect(loadingBounds().left).toBe(before.left); - await click(container.querySelector('[aria-label="Dismiss device warning"]')); - expect(loadingBounds().top).toBe(before.top); - expect(loadingBounds().left).toBe(before.left); - await act(async () => pick.settle(failed)); - }); -}); +} describe("DevicePanel thread switches", () => { - it.each(switches)("does not carry a pending or failed pick to %s", async (_, target) => { - const pick = deferred(); - mocks.open.mockReturnValue(pick.promise); - await show(threadA); - await click(startButton()); - expect(startButton()).toBeNull(); - - await show(target); - expect(startButton()?.disabled).toBe(false); - - await act(async () => pick.settle(failed)); - expect(errors()).toHaveLength(0); - expect(mocks.openDevice).not.toHaveBeenCalled(); - - await show(threadA); - expect(startButton()?.disabled).toBe(false); - expect(errors()).toHaveLength(1); - - await show(target); - expect(startButton()?.disabled).toBe(false); - expect(errors()).toHaveLength(0); - }); - - it.each([ - [ - "pick", - picker, - () => mocks.open, - () => click(startButton()), - () => expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, phoneTarget), - ], - [ - "power-off", - streaming, - () => mocks.close, - () => click(container.querySelector("button")), - () => expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(threadA, streaming.id), - ], - ] as const)( - "applies a late %s to its starting thread after switching environments with the same thread id", - async (_, surface, command, start, expectApplied) => { - const late = deferred(); - command().mockReturnValue(late.promise); - await show(threadA, surface); - await start(); - - await show(otherEnvironment, surface); - await act(async () => late.settle(opened)); - - expectApplied(); - }, - ); - - it("keeps a late power-off error in the environment that started it", async () => { - const powerOff = deferred(); - mocks.close.mockReturnValue(powerOff.promise); - await show(threadA, streaming); - await click(container.querySelector("button")); - - await show(otherEnvironment, streaming); - await act(async () => powerOff.settle(failed)); - - expect(errors()).toHaveLength(0); - expect(mocks.closeSurface).not.toHaveBeenCalled(); - await show(threadA, streaming); - expect(errors()).toHaveLength(1); - }); - it("keeps the latest pick pending across switches away and back", async () => { + setup(); const first = deferred(); const second = deferred(); const latest = deferred(); @@ -287,11 +152,11 @@ describe("DevicePanel thread switches", () => { .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) .mockReturnValueOnce(latest.promise); - await show(threadA); + await using screen = await render(devicePanel(threadA), { container }); await click(startButton()); - await show(threadB); + await screen.rerender(devicePanel(threadB)); await click(startButton()); - await show(threadA); + await screen.rerender(devicePanel(threadA)); await click(startButton()); await act(async () => first.settle(failed)); diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 711d513e24a2..322354763b8d 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -1,7 +1,7 @@ import type { DeviceServiceState } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { deviceHostDiagnostics, deviceHubReadiness, platformSetupStatus } from "./DeviceSetup"; +import { deviceHubReadiness, platformSetupStatus } from "./DeviceSetup"; const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ hosts: [ @@ -29,11 +29,6 @@ const deviceState = (overrides: Partial = {}): DeviceService }); describe("device setup guidance", () => { - it("directs users to install an iOS runtime and create an Android virtual device", () => { - expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); - expect(platformSetupStatus(deviceState(), "android").message).toContain("Device Manager"); - }); - it("waits for complete Android discovery before advising creation of an AVD", () => { const state = deviceState({ hostStatus: "idle", @@ -48,63 +43,6 @@ describe("device setup guidance", () => { ).toContain("Device Manager"); }); - it("preserves a specific missing-tool explanation from the server", () => { - const state = deviceState({ - hosts: [ - { - ...deviceState().hosts[0]!, - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: false, reason: "Android Emulator is missing." }, - ], - }, - ], - }); - expect(platformSetupStatus(state, "android").message).toBe("Android Emulator is missing."); - }); - - it("names an EAS host instead of local tooling when only the cloud can run iOS", () => { - const state = deviceState({ - hostStatus: "idle", - hostStatuses: { eas: { status: "ready" } }, - hosts: [ - { - ...deviceState().hosts[0]!, - platforms: [ - { platform: "ios", available: false, reason: "Xcode was not found." }, - { platform: "android", available: false, reason: "No SDK." }, - ], - }, - { - id: "eas", - kind: "eas", - label: "EAS Simulator", - hubInstalled: true, - agentDeviceInstalled: true, - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: false, reason: "Not supported yet." }, - ], - }, - ], - devices: [ - { - hostId: "eas", - id: "eas-ios", - platform: "ios", - name: "iPhone (EAS Simulator)", - version: "Cloud", - booted: false, - physical: false, - }, - ], - }); - expect(platformSetupStatus(state, "ios")).toEqual({ - ready: true, - message: "EAS Simulator starts iOS Simulators in the cloud with EAS Simulator.", - }); - }); - it("counts a remote device host when this machine has no simulator tooling", () => { const state = deviceState({ hostStatus: "idle", @@ -148,41 +86,4 @@ describe("device setup guidance", () => { }); expect(platformSetupStatus(state, "android").ready).toBe(false); }); - - it("labels ready host warnings in host order and drops them once cleared", () => { - const hosts = [ - { id: "local", label: "This machine" }, - { id: "macmini", label: "Mac Mini" }, - ]; - const state = deviceState({ - hostStatuses: { - macmini: { status: "ready", detail: "adb is not on PATH." }, - local: { status: "ready", detail: "Xcode license not accepted." }, - }, - }); - expect(deviceHostDiagnostics(hosts, state.hostStatuses)).toEqual([ - { hostId: "local", label: "This machine", detail: "Xcode license not accepted." }, - { hostId: "macmini", label: "Mac Mini", detail: "adb is not on PATH." }, - ]); - expect( - deviceHostDiagnostics(hosts, { - ...state.hostStatuses, - local: { status: "ready" }, - }), - ).toEqual([{ hostId: "macmini", label: "Mac Mini", detail: "adb is not on PATH." }]); - }); - - it("omits details from hosts that are not ready or not listed", () => { - const hosts = [ - { id: "local", label: "This machine" }, - { id: "macmini", label: "Mac Mini" }, - ]; - expect( - deviceHostDiagnostics(hosts, { - local: { status: "failed", detail: "Device hub crashed." }, - macmini: { status: "starting", detail: "Connecting over SSH." }, - unknown: { status: "ready", detail: "Stale host warning." }, - }), - ).toEqual([]); - }); }); diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index dd9bd6b45262..18a4fa60212d 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -44,7 +44,7 @@ export function deviceHubReadiness(state: DeviceServiceState) { } /** A ready host keeps its devices usable but may still carry a discovery warning. */ -export function deviceHostDiagnostics( +function deviceHostDiagnostics( hosts: ReadonlyArray>, hostStatuses: DeviceServiceState["hostStatuses"], ) { diff --git a/apps/web/src/components/device/DeviceStreamView.browser.test.tsx b/apps/web/src/components/device/DeviceStreamView.browser.test.tsx deleted file mode 100644 index c0efd1c06898..000000000000 --- a/apps/web/src/components/device/DeviceStreamView.browser.test.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { act, useEffect, useSyncExternalStore } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vite-plus/test"; - -const accessStore = { - value: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }, - listeners: new Set<() => void>(), - refresh() { - accessStore.value = { ...accessStore.value }; - for (const listener of accessStore.listeners) listener(); - }, - subscribe(listener: () => void) { - accessStore.listeners.add(listener); - return () => accessStore.listeners.delete(listener); - }, -}; -vi.mock(import("~/state/device"), async (original) => ({ - ...(await original()), - useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value), - refreshDeviceHubAccess: () => accessStore.refresh(), -})); -let viewerMounts = 0; -let viewerUnmounts = 0; -// The 3D phone frame needs WebGL; the test only cares whether it stays mounted. -vi.mock(import("./DevicePhoneViewport"), async (original) => ({ - ...(await original()), - DevicePhoneViewport: function Viewer(): React.ReactElement { - useEffect(() => { - viewerMounts++; - return () => { - viewerUnmounts++; - }; - }, []); - return <>; - }, -})); -import { pasteTextToFocusComposer } from "../chat/composerFocusRedirect"; -import { DeviceStreamView } from "./DeviceStreamView"; - -// MJPEG frames come from the device hub, so images keep their src without loading it and a -// test publishes a frame by setting the natural size and firing "load". -const imageSources = new WeakMap(); -const framedImages = new WeakSet(); -let images: HTMLImageElement[] = []; -const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src")!; -const naturalWidthDescriptor = Object.getOwnPropertyDescriptor( - HTMLImageElement.prototype, - "naturalWidth", -)!; -const naturalHeightDescriptor = Object.getOwnPropertyDescriptor( - HTMLImageElement.prototype, - "naturalHeight", -)!; -const removeAttribute = Element.prototype.removeAttribute; -function stubImages() { - Object.defineProperty(HTMLImageElement.prototype, "src", { - configurable: true, - get(this: HTMLImageElement) { - return imageSources.get(this) ?? ""; - }, - set(this: HTMLImageElement, value: string) { - if (!images.includes(this)) images.push(this); - imageSources.set(this, value); - }, - }); - for (const name of ["naturalWidth", "naturalHeight"]) { - Object.defineProperty(HTMLImageElement.prototype, name, { - configurable: true, - get(this: HTMLImageElement) { - if (!framedImages.has(this)) return 0; - return name === "naturalWidth" ? 400 : 800; - }, - }); - } - Element.prototype.removeAttribute = function (this: Element, name: string) { - if (this instanceof HTMLImageElement && name === "src") imageSources.delete(this); - return removeAttribute.call(this, name); - }; -} -function restoreImages() { - Object.defineProperty(HTMLImageElement.prototype, "src", srcDescriptor); - Object.defineProperty(HTMLImageElement.prototype, "naturalWidth", naturalWidthDescriptor); - Object.defineProperty(HTMLImageElement.prototype, "naturalHeight", naturalHeightDescriptor); - Element.prototype.removeAttribute = removeAttribute; -} -async function publishFrame(image: HTMLImageElement) { - framedImages.add(image); - await act(async () => image.dispatchEvent(new Event("load"))); -} -let root: Root; -let container: HTMLDivElement; -let primes = 0; -beforeEach(() => { - primes = 0; - viewerMounts = 0; - viewerUnmounts = 0; - images = []; - stubImages(); - container = document.createElement("div"); - container.style.cssText = "width: 400px; height: 800px; display: flex"; - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - restoreImages(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -async function setup(h264 = false, platform: "ios" | "android" = "ios") { - const touches: unknown[] = []; - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - let videoBody: ReadableStreamDefaultController | undefined; - let output: VideoFrameOutputCallback | undefined; - if (h264) { - vi.stubGlobal( - "VideoDecoder", - class { - static isConfigSupported = async () => ({ supported: true }); - state = "unconfigured"; - constructor(callbacks: VideoDecoderInit) { - output = callbacks.output; - } - configure() { - this.state = "configured"; - } - close() { - this.state = "closed"; - } - }, - ); - vi.stubGlobal("EncodedVideoChunk", vi.fn()); - } else { - // Without WebCodecs the iOS client streams MJPEG. - const videoDecoder = Object.getOwnPropertyDescriptor(globalThis, "VideoDecoder"); - if (videoDecoder) { - Reflect.deleteProperty(globalThis, "VideoDecoder"); - onTestFinished(() => { - Object.defineProperty(globalThis, "VideoDecoder", videoDecoder); - }); - } - } - vi.stubGlobal("fetch", (url: string, init: RequestInit) => { - if (url.endsWith("stream.avcc")) { - return Promise.resolve( - new Response( - new ReadableStream({ - start(controller) { - videoBody = controller; - init.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); - }, - }), - ), - ); - } - primes++; - return Promise.resolve(new Response("prime")); - }); - const sockets: Array<{ onmessage?: (event: { data: ArrayBuffer }) => void }> = []; - const sent: unknown[] = []; - vi.stubGlobal( - "WebSocket", - class { - static OPEN = 1; - readyState = 1; - constructor() { - sockets.push(this); - } - onmessage?: (event: { data: ArrayBuffer }) => void; - send(message: string | Uint8Array) { - if (typeof message === "string") { - sent.push(message); - } else if (message[0] === 0x03) { - touches.push(JSON.parse(new TextDecoder().decode(message.subarray(1)))); - } - } - close() {} - }, - ); - const view = (visible: boolean) => ( - - ); - await act(async () => root.render(view(true))); - return { - images, - sent, - view, - touches, - configure() { - const json = new TextEncoder().encode( - JSON.stringify({ width: 400, height: 800, orientation: "portrait" }), - ); - const packet = new Uint8Array(1 + json.length); - packet[0] = 0x82; - packet.set(json, 1); - sockets[0]?.onmessage?.({ data: Uint8Array.of(0x83).buffer }); - sockets[0]?.onmessage?.({ data: packet.buffer }); - videoBody?.enqueue(new Uint8Array([0, 0, 0, 5, 1, 1, 0x64, 0, 0x1f])); - }, - frame() { - const source = new OffscreenCanvas(400, 800); - source.getContext("2d")!.fillRect(0, 0, 400, 800); - output?.(new VideoFrame(source, { timestamp: 0 })); - }, - }; -} - -const alert = () => container.querySelector('[role="alert"]'); -const reconnect = () => - act(async () => - [...container.querySelectorAll("button")] - .find((button) => button.textContent?.includes("Reconnect"))! - .click(), - ); - -it.each([ - { name: "middle", button: 1, buttons: 4 }, - { name: "right", button: 2, buttons: 2 }, -])("ignores $name-button drags and forwards the next primary drag", async ({ button, buttons }) => { - const { touches, configure } = await setup(); - await act(async () => configure()); - const viewport = container.querySelector("canvas")!.parentElement!; - viewport.style.width = "400px"; - viewport.style.height = "800px"; - // Synthetic pointer events do not register an active browser pointer to capture. - viewport.setPointerCapture = () => {}; - const rect = viewport.getBoundingClientRect(); - const pointer = (type: string, button: number, buttons: number, y: number) => - viewport.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - pointerId: 1, - pointerType: "mouse", - button, - buttons, - clientX: rect.left + 100, - clientY: rect.top + y, - }), - ); - - await act(async () => { - pointer("pointerdown", button, buttons, 600); - pointer("pointermove", -1, buttons, 200); - pointer("pointerup", button, 0, 200); - }); - expect(touches).toEqual([]); - - await act(async () => { - pointer("pointerdown", 0, 1, 600); - pointer("pointermove", -1, 1, 200); - pointer("pointerup", 0, 0, 200); - pointer("pointermove", -1, 0, 400); - }); - expect(touches).toEqual([ - { type: "begin", x: 0.25, y: 0.75 }, - { type: "move", x: 0.25, y: 0.25 }, - { type: "end", x: 0.25, y: 0.25 }, - ]); -}); - -it("removes MJPEG requests while hidden and reconnects when shown", async () => { - const { images, view } = await setup(); - expect(images[0]!.src).toContain("stream.mjpeg"); - await act(async () => root.render(view(false))); - expect(container.querySelectorAll("img")).toHaveLength(0); - expect(images[0]!.src).toBe(""); - expect(vi.getTimerCount()).toBe(0); - await act(async () => root.render(view(true))); - expect(images.at(-1)!.src).toContain("stream.mjpeg"); -}); - -it("offers Reconnect after the shared timeout and receives a frame after retry with unchanged access", async () => { - const { images } = await setup(); - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - expect(alert()?.textContent).toContain("No video"); - expect(images[0]!.src).toBe(""); - await reconnect(); - expect(alert()).toBeNull(); - expect(images[1]!.src).toContain("stream.mjpeg"); - await publishFrame(images[1]!); - expect(container.querySelector('[role="status"]')).toBeNull(); - expect(container.querySelectorAll("button")).toHaveLength(0); - expect(vi.getTimerCount()).toBe(0); -}); - -it("starts exactly one new stream per Reconnect press", async () => { - await setup(); - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - expect(primes).toBe(1); - await reconnect(); - expect(primes).toBe(2); -}); - -it("keeps the 3D viewer mounted across iOS video recovery and releases it when hidden", async () => { - const { configure, frame, view } = await setup(true); - await act(async () => configure()); - expect(viewerMounts).toBe(0); - await act(async () => frame()); - expect(viewerMounts).toBe(1); - await act(async () => { - await vi.advanceTimersByTimeAsync(15_000); - }); - expect(viewerUnmounts).toBe(0); - expect(viewerMounts).toBe(1); - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); - configure(); - }); - await act(async () => frame()); - expect(viewerMounts).toBe(1); - await act(async () => root.render(view(false))); - expect(viewerUnmounts).toBe(1); - expect(vi.getTimerCount()).toBe(0); -}); - -it("types a paste on the Android screen into the device and lets Ctrl+V reach the paste", async () => { - const { sent } = await setup(true, "android"); - const screen = container.querySelector('[role="application"]')!; - screen.focus(); - expect(document.activeElement).toBe(screen); - const chord = new KeyboardEvent("keydown", { - key: "v", - code: "KeyV", - ctrlKey: true, - bubbles: true, - cancelable: true, - }); - screen.dispatchEvent(chord); - expect(chord.defaultPrevented).toBe(false); - const clipboardData = new DataTransfer(); - clipboardData.setData("text/plain", "pässwörd 1"); - const composerPastes: Array = []; - const capturePaste = (event: ClipboardEvent) => - composerPastes.push(pasteTextToFocusComposer(event)); - window.addEventListener("paste", capturePaste, true); - onTestFinished(() => window.removeEventListener("paste", capturePaste, true)); - const paste = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); - screen.dispatchEvent(paste); - expect(paste.defaultPrevented).toBe(true); - expect(sent.map((message) => JSON.parse(message as string) as unknown)).toEqual([ - { type: "text", text: "pässwörd 1" }, - ]); - expect(composerPastes).toEqual([null]); -}); diff --git a/apps/web/src/components/device/deviceFrameLayout.test.ts b/apps/web/src/components/device/deviceFrameLayout.test.ts deleted file mode 100644 index c36c8141f89e..000000000000 --- a/apps/web/src/components/device/deviceFrameLayout.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { fitDeviceFrame } from "./deviceFrameLayout"; - -it("fits a flat device beside the fixed controls rail", () => { - const frame = fitDeviceFrame(9 / 19.5, 480, 1_000, 56); - expect(frame.width + 56).toBeLessThanOrEqual(480); - expect(frame.height).toBeLessThanOrEqual(1_000); - expect(frame.width / frame.height).toBeCloseTo(9 / 19.5); -}); diff --git a/apps/web/src/components/device/phoneTrackpad.test.ts b/apps/web/src/components/device/phoneTrackpad.test.ts deleted file mode 100644 index b8fb3f334188..000000000000 --- a/apps/web/src/components/device/phoneTrackpad.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, expect, it, vi } from "vite-plus/test"; -import { bindPhoneTrackpad } from "./phoneTrackpad"; - -afterEach(() => vi.useRealTimers()); - -class Canvas extends EventTarget { - getBoundingClientRect() { - return { width: 400, height: 800 } as DOMRect; - } -} -const wheel = (ctrlKey = false) => - Object.assign(new Event("wheel", { cancelable: true }), { - deltaX: 16, - deltaY: -20, - deltaMode: 0, - ctrlKey, - }); -const gesture = (type: string, scale: number) => - Object.assign(new Event(type, { cancelable: true }), { scale }); - -it("consumes scrolling and page zoom only on the bound canvas and releases listeners on detach", () => { - const canvas = new Canvas(); - const navigate = vi.fn(() => true); - const listeners = vi.spyOn(canvas, "addEventListener"); - const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() }); - const swipe = wheel(); - canvas.dispatchEvent(swipe); - const pinch = wheel(true); - canvas.dispatchEvent(pinch); - expect(swipe.defaultPrevented).toBe(true); - expect(pinch.defaultPrevented).toBe(true); - expect(navigate.mock.calls).toEqual([[{ type: "orbit", x: -0.04, y: 0.025 }]]); - expect( - listeners.mock.calls.every( - ([, , options]) => typeof options === "object" && options.passive === false, - ), - ).toBe(true); - binding.dispose(); - const detached = wheel(true); - canvas.dispatchEvent(detached); - expect(detached.defaultPrevented).toBe(false); - expect(navigate).toHaveBeenCalledOnce(); -}); - -it("consumes Safari pinch without zooming the model and resumes orbit after cancellation", () => { - const canvas = new Canvas(); - const navigate = vi.fn(() => true); - const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() }); - canvas.dispatchEvent(gesture("gesturestart", 1)); - canvas.dispatchEvent(gesture("gesturechange", 1.2)); - canvas.dispatchEvent(wheel(true)); - expect(navigate).not.toHaveBeenCalled(); - binding.cancel(); - canvas.dispatchEvent(wheel()); - expect(navigate).toHaveBeenCalledOnce(); - binding.dispose(); -}); - -it("normalizes Chrome and Safari pinch, expires wheel sequences and cancels on detach", () => { - vi.useFakeTimers(); - const canvas = new Canvas(); - const pinch = { begin: vi.fn(() => true), move: vi.fn(), end: vi.fn() }; - const navigate = vi.fn(() => true); - const binding = bindPhoneTrackpad(canvas, { navigate, endWheel: vi.fn() }, pinch); - canvas.dispatchEvent(wheel(true)); - canvas.dispatchEvent(wheel(true)); - expect(pinch.begin).toHaveBeenCalledOnce(); - expect(pinch.move.mock.calls).toEqual([[0.2], [0.2]]); - vi.advanceTimersByTime(180); - expect(pinch.end).toHaveBeenCalledOnce(); - canvas.dispatchEvent(gesture("gesturestart", 1)); - canvas.dispatchEvent(gesture("gesturechange", 1.2)); - expect(pinch.move).toHaveBeenLastCalledWith(Math.log(1.2)); - canvas.dispatchEvent(wheel(true)); - expect(pinch.move).toHaveBeenCalledTimes(3); - binding.dispose(); - const count = pinch.move.mock.calls.length; - vi.advanceTimersByTime(500); - canvas.dispatchEvent(wheel(true)); - expect(pinch.move).toHaveBeenCalledTimes(count); - expect(navigate).not.toHaveBeenCalled(); -}); - -it("keeps a paused wheel orbit active until native release or browser fallback", () => { - vi.useFakeTimers(); - const canvas = new Canvas(); - const navigate = vi.fn(() => true); - const endWheel = vi.fn(); - const binding = bindPhoneTrackpad(canvas, { navigate, endWheel }); - canvas.dispatchEvent(wheel()); - vi.advanceTimersByTime(500); - expect(endWheel).not.toHaveBeenCalled(); - binding.endOrbit(); - expect(endWheel).toHaveBeenCalledOnce(); - canvas.dispatchEvent(wheel()); - vi.advanceTimersByTime(1199); - expect(endWheel).toHaveBeenCalledOnce(); - vi.advanceTimersByTime(1); - expect(endWheel).toHaveBeenCalledTimes(2); - canvas.dispatchEvent(wheel()); - canvas.dispatchEvent(Object.assign(wheel(), { momentum: true })); - expect(endWheel).toHaveBeenCalledTimes(3); - expect(navigate).toHaveBeenCalledTimes(3); - binding.dispose(); -}); diff --git a/apps/web/src/components/device/useDeviceControls.browser.test.tsx b/apps/web/src/components/device/useDeviceControls.browser.test.tsx index cd5df4f7c064..7db819cdc5b7 100644 --- a/apps/web/src/components/device/useDeviceControls.browser.test.tsx +++ b/apps/web/src/components/device/useDeviceControls.browser.test.tsx @@ -1,9 +1,10 @@ import { EnvironmentId, type DeviceDetail, type DeviceSummary } from "@t3tools/contracts"; import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; import * as Cause from "effect/Cause"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; type Result = { _tag: "Success"; value: DeviceDetail } | { _tag: "Failure"; cause: unknown }; const { read, action, subscribe } = vi.hoisted(() => ({ @@ -54,7 +55,6 @@ function deferred() { }); return { promise, resolve }; } -let root: Root; let controls: DeviceControls; function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHubAccess | null }) { const next = useDeviceControls({ @@ -68,27 +68,17 @@ function Probe({ visible, access = null }: { visible: boolean; access?: DeviceHu }, [next]); return null; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { read.mockReset(); action.mockReset(); subscribe.mockReset(); subscribe.mockReturnValue(() => {}); read.mockResolvedValue(snapshot("light")); - root = createRoot(document.createElement("div")); -}); -afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); -}); -async function mount() { - await act(async () => { - root.render(); - }); } it("serializes rapid actions and retains confirmed settings while pending or after failure", async () => { - await mount(); + setup(); + await using _screen = await render(); const response = deferred(); action.mockReturnValueOnce(response.promise); let pending!: Promise; @@ -111,50 +101,17 @@ it("serializes rapid actions and retains confirmed settings while pending or aft expect(controls.disabled).toBe(false); }); -it("serializes host actions across hide/reopen and discards detached results", async () => { - await mount(); - const response = deferred(); - action.mockReturnValueOnce(response.promise); - let pending!: Promise; - await act(async () => { - pending = controls.act({ type: "setAppearance", value: "dark" }); - }); - await act(async () => root.render()); - expect(controls.disabled).toBe(true); - await act(async () => root.render()); - await act(async () => controls.act({ type: "setAppearance", value: "light" })); - expect(action).toHaveBeenCalledOnce(); - expect(controls.disabled).toBe(true); - const confirmed = deferred(); - read.mockReturnValueOnce(confirmed.promise); - await act(async () => { - response.resolve(snapshot("dark")); - await response.promise; - }); - expect(controls.detail?.settings.appearance).toBe("light"); - expect(controls.disabled).toBe(true); - await act(async () => { - confirmed.resolve(snapshot("dark")); - await pending; - }); - expect(controls.detail?.settings.appearance).toBe("dark"); - expect(controls.disabled).toBe(false); - action.mockResolvedValueOnce(snapshot("dark")); - await act(async () => controls.act({ type: "setAppearance", value: "dark" })); - expect(action).toHaveBeenCalledTimes(2); - expect(controls.detail?.settings.appearance).toBe("dark"); -}); - it("keeps actions disabled when settings cannot be confirmed after a detached command", async () => { - await mount(); + setup(); + await using screen = await render(); const response = deferred(); action.mockReturnValueOnce(response.promise); let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); - await act(async () => root.render()); - await act(async () => root.render()); + await screen.rerender(); + await screen.rerender(); read.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); await act(async () => { response.resolve(snapshot("dark")); @@ -165,35 +122,19 @@ it("keeps actions disabled when settings cannot be confirmed after a detached co expect(controls.error).toBe("Device offline"); await act(async () => controls.act({ type: "setAppearance", value: "light" })); expect(action).toHaveBeenCalledOnce(); - await act(async () => root.render()); - await act(async () => root.render()); - expect(controls.disabled).toBe(false); - expect(controls.error).toBeNull(); -}); - -it("disables stale settings after a failed reopen read and recovers on a successful read", async () => { - await mount(); - await act(async () => root.render()); - read.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); - await act(async () => root.render()); - expect(controls.detail).toBeNull(); - expect(controls.disabled).toBe(true); - expect(controls.error).toBe("Device offline"); - await act(async () => controls.act({ type: "setAppearance", value: "dark" })); - expect(action).not.toHaveBeenCalled(); - await act(async () => root.render()); - await act(async () => root.render()); - expect(controls.detail?.settings.appearance).toBe("light"); + await screen.rerender(); + await screen.rerender(); expect(controls.disabled).toBe(false); expect(controls.error).toBeNull(); }); it("does not let an older refresh overwrite a newer confirmed action", async () => { - await mount(); - await act(async () => root.render()); + setup(); + await using screen = await render(); + await screen.rerender(); const refresh = deferred(); read.mockReturnValueOnce(refresh.promise); - await act(async () => root.render()); + await screen.rerender(); action.mockResolvedValueOnce(snapshot("dark")); await act(async () => controls.act({ type: "setAppearance", value: "dark" })); await act(async () => { @@ -202,41 +143,3 @@ it("does not let an older refresh overwrite a newer confirmed action", async () }); expect(controls.detail?.settings.appearance).toBe("dark"); }); - -it.each(["hidden", "access renewed"] as const)( - "uses the detail snapshot until a new foreground event after the subscription is %s", - async (restart) => { - const access: DeviceHubAccess = { - httpBase: "https://remote.test/api/device-hub", - wsBase: "wss://remote.test/api/device-hub", - query: {}, - credentials: false, - }; - const detail = snapshot("light"); - if (detail._tag !== "Success") throw new Error("Expected a successful snapshot"); - read.mockResolvedValue({ - ...detail, - value: { ...detail.value, foregroundApp: { id: "snapshot.app" } }, - }); - await act(async () => { - root.render(); - }); - const oldEvent = subscribe.mock.calls[0]![1]; - await act(async () => oldEvent({ id: "previous.live.app" })); - expect(controls.foregroundApp?.id).toBe("previous.live.app"); - if (restart === "hidden") { - await act(async () => root.render()); - await act(async () => root.render()); - } else { - await act(async () => - root.render(), - ); - } - expect(controls.foregroundApp?.id).toBe("snapshot.app"); - await act(async () => oldEvent({ id: "late.previous.app" })); - expect(controls.foregroundApp?.id).toBe("snapshot.app"); - const newEvent = subscribe.mock.calls[1]![1]; - await act(async () => newEvent({ id: "current.live.app" })); - expect(controls.foregroundApp?.id).toBe("current.live.app"); - }, -); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx index de0f62949963..24b6ee5b8f55 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.browser.test.tsx @@ -1,8 +1,9 @@ import type { CodeViewDiffItem, FileDiffMetadata, SelectedLineRange } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import { act, StrictMode, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileDiffIdentityKey } from "~/lib/diffRendering"; @@ -51,7 +52,6 @@ import type { StyledDiffCodeView } from "./StyledDiffCodeView"; const target = DraftId.make("diff-draft-test"); const range: SelectedLineRange = { start: 1, end: 1, side: "additions" }; -let root: Root; let container: HTMLDivElement; function file( @@ -75,28 +75,26 @@ function file( }; } -function render( +function view( files: ReturnType[], codeViewKey = "initial", pendingFiles: FileDiffMetadata[] = [], ) { - act(() => - root.render( - - null} - renderHeaderPrefix={() => null} - onRevealSearchMatch={() => {}} - /> - , - ), + return ( + + null} + renderHeaderPrefix={() => null} + onRevealSearchMatch={() => {}} + /> + ); } @@ -105,7 +103,7 @@ function click(label: string) { (node) => node.textContent === label, ); if (!button) throw new Error(`Missing button: ${label}`); - act(() => button.click()); + return act(() => button.click()); } function editor() { @@ -115,7 +113,7 @@ function editor() { } function typeComment(text: string) { - act(() => { + return act(() => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call( editor(), text, @@ -124,82 +122,35 @@ function typeComment(text: string) { }); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); +function setup(): Disposable { + const reset = () => useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); + reset(); container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true); - vi.unstubAllGlobals(); -}); + return { [Symbol.dispose]: reset }; +} describe("diff comment drafts", () => { - it.each(["removed file", "replaced rename"] as const)( - "allows a fresh comment after a %s loses its draft", - (scenario) => { - const original = - scenario === "removed file" - ? file("a.ts") - : file("shared.ts", 1, "rename-changed", "old.ts"); - render([original, file("b.ts")]); - click(`Comment on ${original.filePath}`); - typeComment("Discard this draft"); - const pending = - scenario === "removed file" - ? [] - : [ - { - ...file("shared.ts", 2, "rename-changed", "other.ts").fileDiff, - hunks: [], - cacheKey: "replacement:pending", - }, - ]; - - render([file("b.ts")], "refreshed", pending); - expect(container.querySelector("textarea")).toBeNull(); - click("Comment on b.ts"); - expect(editor().value).toBe(""); - typeComment(" Review b.ts "); - click("Comment"); - - expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([ - expect.objectContaining({ - filePath: "b.ts", - text: "Review b.ts", - selection: { ...range, endSide: "additions" }, - }), - ]); - render([original, file("b.ts")], "returned"); - expect(container.querySelector("textarea")).toBeNull(); - expect(container.querySelector("mark")).toBeNull(); - expect(container.querySelector("p")?.textContent).toBe("Review b.ts"); - }, - ); - - it("keeps the text and selected line through refresh and a provisional lazy patch", () => { + it("keeps the text and selected line through refresh and a provisional lazy patch", async () => { + using _drafts = setup(); const original = file("shared.ts", 1, "rename-pure", "old.ts"); - render([original]); - click("Comment on shared.ts"); - typeComment("Keep this draft"); + await using screen = await render(view([original]), { container }); + await click("Comment on shared.ts"); + await typeComment("Keep this draft"); const refreshed = file("shared.ts", 2, "rename-pure", "old.ts"); - render([refreshed], "refreshed"); + await screen.rerender(view([refreshed], "refreshed")); expect(editor().value).toBe("Keep this draft"); expect(container.querySelector("mark")?.textContent).toBe("Line 1"); - render([], "loading", [ - { ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" }, - ]); + await screen.rerender( + view([], "loading", [ + { ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" }, + ]), + ); expect(container.querySelector("textarea")).toBeNull(); - render([refreshed], "loaded"); + await screen.rerender(view([refreshed], "loaded")); expect(editor().value).toBe("Keep this draft"); expect(container.querySelector("mark")?.textContent).toBe("Line 1"); - click("Comment"); + await click("Comment"); expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([ expect.objectContaining({ filePath: "shared.ts", diff --git a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx b/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx deleted file mode 100644 index e3ab74f2d51c..000000000000 --- a/apps/web/src/components/diffs/DiffFileTree.browser.test.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import type { CodeViewScrollTarget } from "@pierre/diffs"; -import type { FileTree as FileTreeModel } from "@pierre/trees"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { FileDiffMetadata } from "@pierre/diffs"; - -import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; -import { diffFileTreeEntries } from "./diffFileTree.logic"; -import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; - -const captured = vi.hoisted(() => ({ model: null as unknown })); -// Read the tree model the component builds, so tests can assert its selection and expansion. -vi.mock(import("@pierre/trees/react"), async (original) => { - const actual = await original(); - const RealFileTree = actual.FileTree; - return { - ...actual, - FileTree: ((props: ComponentProps) => { - captured.model = props.model; - return ; - }) as typeof RealFileTree, - }; -}); - -const entries: DiffFileTreeEntry[] = [ - { path: "01-tall.ts", status: "modified" }, - { path: "02-short.ts", status: "modified" }, - { path: "03-medium.ts", status: "modified" }, -]; - -describe("diff tree file activation", () => { - let root: Root | undefined; - let container: HTMLDivElement; - const targets: CodeViewScrollTarget[] = []; - const viewer = { - getInstance: () => viewer, - scrollTo: (target: CodeViewScrollTarget) => targets.push(target), - }; - - function Panel({ - files = entries, - selectedPath = null, - revealRequestId = 0, - }: { - files?: DiffFileTreeEntry[]; - selectedPath?: string | null; - revealRequestId?: number; - }) { - const reveal = useCodeViewFileReveal(viewer, "working-tree"); - return ( - reveal(`${path}\0${path}`)} - /> - ); - } - - const model = (): FileTreeModel => captured.model as FileTreeModel; - - async function mount(props: Parameters[0] = {}) { - root ??= createRoot(container); - await act(async () => root!.render()); - } - - function row(path: string): HTMLElement { - const host = container.querySelector("file-tree-container"); - const element = (host?.shadowRoot ?? container).querySelector( - `[data-item-path="${CSS.escape(path)}"]`, - ); - expect(element, `row ${path}`).not.toBeNull(); - return element!; - } - - function fileRows(): HTMLElement[] { - const host = container.querySelector("file-tree-container"); - return [ - ...(host?.shadowRoot ?? container).querySelectorAll( - '[role="treeitem"][data-item-type="file"]', - ), - ]; - } - - async function activate(target: string | HTMLElement, modifiers: MouseEventInit = {}) { - await act(async () => { - (typeof target === "string" ? row(target) : target).dispatchEvent( - new MouseEvent("click", { - bubbles: true, - composed: true, - button: 0, - detail: 1, - ...modifiers, - }), - ); - }); - } - - beforeEach(() => { - targets.length = 0; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - container.style.cssText = "display: flex; flex-direction: column; width: 320px; height: 480px"; - document.body.append(container); - }); - - afterEach(async () => { - await act(async () => root?.unmount()); - root = undefined; - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("mounts a file-to-symlink type change as one tree row", async () => { - const typeChange = diffFileTreeEntries([ - { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, - { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata, - { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata, - ]); - await mount({ files: [...typeChange] }); - - const tree = model(); - expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false); - expect(tree.getItem("CLAUDE.md")?.isDirectory()).toBe(false); - await activate("AGENTS.md"); - expect(targets).toEqual([{ type: "item", id: "AGENTS.md\u0000AGENTS.md", align: "start" }]); - }); - - it("reissues the reveal when the sole selected file is activated again", async () => { - await mount(); - await activate("02-short.ts"); - expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); - await activate("02-short.ts"); - expect(targets).toEqual([ - { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, - { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, - ]); - }); - - it("reveals newly selected files once in either direction", async () => { - await mount(); - await activate("02-short.ts"); - await activate("01-tall.ts"); - await activate("03-medium.ts"); - expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual( - ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`), - ); - }); - - it("keeps focus-only navigation separate from button activation", async () => { - await mount(); - await activate("02-short.ts"); - await act(async () => model().getItem("01-tall.ts")!.focus()); - expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); - expect(targets).toHaveLength(1); - await activate("01-tall.ts", { detail: 0 }); - await activate("01-tall.ts", { detail: 0 }); - expect(targets).toHaveLength(3); - }); - - it.each(["ctrlKey", "metaKey"] as const)( - "does not reveal a selected file that a %s click deselects", - async (modifier) => { - await mount(); - await activate("02-short.ts"); - await activate("02-short.ts", { [modifier]: true }); - expect(model().getSelectedPaths()).toEqual([]); - expect(targets).toHaveLength(1); - }, - ); - - it("lets a click narrow multiple selected files without a second reveal", async () => { - await mount(); - await activate("02-short.ts"); - await act(async () => model().getItem("01-tall.ts")!.select()); - expect(model().getSelectedPaths()).toHaveLength(2); - targets.length = 0; - await activate("02-short.ts"); - expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); - expect(targets).toHaveLength(1); - }); - - it("leaves directory selection and expansion to the tree", async () => { - await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); - const directory = model().getItem("src/")!; - if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); - expect(directory.isExpanded()).toBe(true); - await activate("src/"); - expect(directory.isExpanded()).toBe(false); - await activate("src/"); - expect(directory.isExpanded()).toBe(true); - expect(targets).toEqual([]); - }); - - it("reorders refreshed files without reopening a collapsed folder", async () => { - const files: DiffFileTreeEntry[] = [ - { path: "src/state/shell.ts", status: "modified" }, - { path: "src/features/route.ts", status: "added" }, - ]; - await mount({ files }); - const initialFolder = model().getItem("src/features/")!; - if (!("collapse" in initialFolder)) throw new Error("Expected the directory handle"); - await act(async () => initialFolder.collapse()); - await act(async () => { - root!.render(); - }); - const folder = model().getItem("src/features/")!; - if (!("isExpanded" in folder)) throw new Error("Expected the directory handle"); - expect(folder.isExpanded()).toBe(false); - }); - - it("reveals real diff paths and statuses when a directory is replaced by a file", async () => { - const files: DiffFileTreeEntry[] = [ - { path: "office/config.ts", status: "deleted" }, - { path: "office", status: "added" }, - ]; - await mount({ files, selectedPath: "office" }); - expect(fileRows().map((row) => row.getAttribute("data-item-git-status"))).toEqual([ - "deleted", - "added", - ]); - expect(fileRows()[1]!.getAttribute("aria-selected")).toBe("true"); - expect(targets).toEqual([]); - await activate(fileRows()[1]!); - await activate(fileRows()[0]!); - await activate("office/"); - expect(targets).toEqual([ - { type: "item", id: "office\0office", align: "start" }, - { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, - ]); - expect(row("office/").getAttribute("aria-expanded")).toBe("false"); - await mount({ files, selectedPath: "office/config.ts" }); - expect(row("office/").getAttribute("aria-expanded")).toBe("true"); - expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); - expect(targets).toHaveLength(2); - }); - - it("keeps selection, focus, and collapsed folders through a colliding slice and refresh", async () => { - const files: DiffFileTreeEntry[] = [ - { path: "office", status: "deleted" }, - { path: "src/route.ts", status: "modified" }, - ]; - await mount({ files }); - await activate("src/"); - await activate("office"); - await act(async () => model().focusPath("office")); - expect(model().getFocusedPath()).toBe("office"); - expect(row("office").getAttribute("aria-selected")).toBe("true"); - const appended: DiffFileTreeEntry[] = [...files, { path: "office/config.ts", status: "added" }]; - await mount({ files: appended }); - expect(row("src/").getAttribute("aria-expanded")).toBe("false"); - expect(fileRows()[0]!.getAttribute("aria-selected")).toBe("true"); - expect(row("office/").getAttribute("aria-selected")).toBe("false"); - expect.soft(model().getFocusedPath()).toBe("office\u200b"); - expect(targets).toEqual([{ type: "item", id: "office\0office", align: "start" }]); - await activate(fileRows()[0]!); - await activate(fileRows()[1]!); - expect(targets).toEqual([ - { type: "item", id: "office\0office", align: "start" }, - { type: "item", id: "office\0office", align: "start" }, - { type: "item", id: "office/config.ts\0office/config.ts", align: "start" }, - ]); - await activate("office/"); - await mount({ files: appended.map((file) => ({ ...file })) }); - expect(row("src/").getAttribute("aria-expanded")).toBe("false"); - expect(row("office/").getAttribute("aria-expanded")).toBe("false"); - await activate(fileRows()[0]!); - expect(targets.at(-1)).toEqual({ type: "item", id: "office\0office", align: "start" }); - expect(targets).toHaveLength(4); - }); - - it("preserves filename and extension icons when files become directory prefixes", async () => { - const files: DiffFileTreeEntry[] = [ - { path: "AGENTS.md", status: "deleted" }, - { path: "config.ts", status: "deleted" }, - ]; - const rootFileIcons = () => - fileRows() - .filter((row) => row.getAttribute("aria-level") === "1") - .map((row) => row.querySelector('[data-item-section="icon"] use')?.getAttribute("href")); - await mount({ files }); - const icons = rootFileIcons(); - expect(icons).toEqual(["#t3-file-icon-agents", "#file-tree-builtin-typescript"]); - await mount({ - files: [ - ...files, - { path: "AGENTS.md/note.txt", status: "added" }, - { path: "config.ts/note.txt", status: "added" }, - ], - }); - expect(rootFileIcons()).toEqual(icons); - }); - - it("preserves a collapsed controlled selection through paging until explicitly revealed", async () => { - const files: DiffFileTreeEntry[] = [ - { path: "src/office", status: "deleted" }, - { path: "src/route.ts", status: "modified" }, - ]; - await mount({ files, selectedPath: "src/office" }); - expect(model().getSelectedPaths()).toEqual(["src/office"]); - expect(targets).toEqual([]); - await activate("src/office"); - expect(targets).toEqual([{ type: "item", id: "src/office\0src/office", align: "start" }]); - - const folder = model().getItem("src/")!; - if (!("collapse" in folder)) throw new Error("Expected the directory handle"); - await act(async () => folder.collapse()); - expect(row("src/").getAttribute("aria-expanded")).toBe("false"); - const scroll = vi.spyOn(model(), "scrollToPath"); - const appended: DiffFileTreeEntry[] = [...files, { path: "src/office/x.ts", status: "added" }]; - await mount({ files: appended, selectedPath: "src/office" }); - expect.soft(row("src/").getAttribute("aria-expanded")).toBe("false"); - expect.soft(scroll).not.toHaveBeenCalled(); - const selected = model().getSelectedPaths(); - expect(selected).toHaveLength(1); - expect(model().getItem(selected[0]!)?.isDirectory()).toBe(false); - expect(targets).toHaveLength(1); - - await act(async () => folder.collapse()); - scroll.mockClear(); - await mount({ files: appended, selectedPath: "src/office", revealRequestId: 1 }); - expect(row("src/").getAttribute("aria-expanded")).toBe("true"); - expect(model().getSelectedPaths()).toEqual(selected); - expect(row(selected[0]!).getAttribute("aria-selected")).toBe("true"); - expect(scroll).toHaveBeenCalledOnce(); - expect(targets).toHaveLength(1); - await activate(selected[0]!); - expect(targets).toEqual([ - { type: "item", id: "src/office\0src/office", align: "start" }, - { type: "item", id: "src/office\0src/office", align: "start" }, - ]); - - await act(async () => folder.collapse()); - await mount({ files: appended, selectedPath: "src/route.ts", revealRequestId: 1 }); - expect(row("src/").getAttribute("aria-expanded")).toBe("true"); - expect(model().getSelectedPaths()).toEqual(["src/route.ts"]); - expect(targets).toHaveLength(2); - }); -}); diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx deleted file mode 100644 index 042f5da2cd9a..000000000000 --- a/apps/web/src/components/diffs/StyledDiffCodeView.browser.test.tsx +++ /dev/null @@ -1,650 +0,0 @@ -import { - CodeView, - FileDiff, - FileRenderer, - getSharedHighlighter, - type CodeViewItem, - type CodeViewScrollTarget, - type FileContents, -} from "@pierre/diffs"; -import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react"; -import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; -import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; -import { getDiffLineStat, getRenderablePatch } from "../../lib/diffRendering"; -import { - act, - StrictMode, - useEffect, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, - type ReactElement, - type Ref, -} from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - workers: [] as Worker[], - terminations: 0, - requests: [] as WorkerRequest[], - pools: new Map>(), - renderPools: [] as (WorkerPoolManager | undefined)[], - failWorkers: false, - holdInitialization: false, - heldResponses: [] as (() => void)[], - onInitializationHeld: undefined as (() => void) | undefined, -})); - -vi.mock(import("../../hooks/useTheme"), async (original) => ({ - ...(await original()), - useRenderedAppearance: (() => "dark") as never, -})); - -// Wraps Pierre's real worker so the suite can count workers, read requests and hold startup. -// Vite's `?worker` export is a factory that returns a plain Worker, so this patches the instance. -vi.mock("@pierre/diffs/worker/worker.js?worker", async (original) => { - const actual = await original<{ default: new () => Worker }>(); - return { - ...actual, - default: function TestDiffsWorker() { - if (testState.failWorkers) throw new Error("Worker creation failed"); - const worker = new actual.default(); - testState.workers.push(worker); - const addEventListener = worker.addEventListener.bind(worker); - const postMessage = worker.postMessage.bind(worker); - const terminate = worker.terminate.bind(worker); - worker.addEventListener = (type: string, listener: EventListenerOrEventListenerObject) => { - if (type !== "message" || typeof listener !== "function") { - addEventListener(type, listener); - return; - } - addEventListener("message", (event) => { - const data = (event as MessageEvent).data; - const deliver = () => listener(event); - if ( - testState.holdInitialization && - data.type === "success" && - data.requestType === "initialize" - ) { - testState.heldResponses.push(deliver); - if (testState.heldResponses.length === 2) testState.onInitializationHeld?.(); - } else { - deliver(); - } - }); - }; - worker.postMessage = (message: WorkerRequest) => { - testState.requests.push(message); - postMessage(message); - }; - worker.terminate = () => { - testState.terminations += 1; - terminate(); - }; - return worker; - }, - }; -}); - -// Pierre's real viewer virtualizes into a shadow root; this stand-in renders each file through -// the same worker pool and keeps the highlighted HTML readable. -vi.mock(import("@pierre/diffs/react"), async (original) => ({ - ...(await original()), - CodeView: ((props: CodeViewProps) => - props.items?.map((item) => - item.type === "file" ? : null, - )) as never, -})); - -import { StyledDiffCodeView } from "./StyledDiffCodeView"; -import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; - -function FileOutput({ file }: { file: FileContents }) { - const pool = useWorkerPool(); - const [html, setHtml] = useState(""); - useEffect(() => { - testState.renderPools.push(pool); - const renderer = new FileRenderer( - { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" }, - undefined, - render, - pool, - ); - function render() { - const result = renderer.renderFile(file); - if (result) setHtml(renderer.renderFullHTML(result)); - } - render(); - return () => renderer.cleanUp(); - }, [file, pool]); - return
{html}
; -} - -const files = [ - { name: "notes.txt", contents: "Plain text is ready.\n", cacheKey: "plain-text" }, - { name: "answer.ts", contents: "const answer = 42;\n", cacheKey: "typescript" }, -] satisfies FileContents[]; - -function Views({ count }: { count: number }) { - const [draft, setDraft] = useState("Draft"); - return ( - <> - - {files.slice(0, count).map((file) => ( - - ))} - - ); -} - -function renderViews(count: number) { - return ( - - - - ); -} - -const roots: Root[] = []; - -function mount(element: ReactElement) { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - return { - root, - render: (next: ReactElement) => act(async () => root.render(next)), - initial: act(async () => root.render(element)), - }; -} - -async function unmountAll() { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); -} - -const codeFile = (name: string) => - document.querySelector(`[data-code-file="${name}"]`)?.textContent ?? ""; -const loadingViews = () => document.querySelectorAll('[role="status"]').length; - -describe("code-view worker lifecycle", () => { - beforeEach(() => { - testState.workers = []; - testState.terminations = 0; - testState.requests = []; - testState.pools.clear(); - testState.renderPools = []; - testState.failWorkers = false; - testState.holdInitialization = false; - testState.heldResponses = []; - testState.onInitializationHeld = undefined; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - // Pins the pool at two workers whatever machine runs the suite. - Object.defineProperty(navigator, "hardwareConcurrency", { configurable: true, value: 2 }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const initialize = WorkerPoolManager.prototype.initialize; - vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function ( - this: WorkerPoolManager, - ...args - ) { - const pending = initialize.apply(this, args); - testState.pools.set(this, pending); - return pending; - }); - }); - - afterEach(async () => { - await unmountAll(); - await vi.runOnlyPendingTimersAsync(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - delete (navigator as { hardwareConcurrency?: number }).hardwareConcurrency; - }); - - it("starts on demand, keeps quick reopens warm, and expires the last closed pool", async () => { - const view = mount(renderViews(0)); - await view.initial; - const button = document.querySelector("button")!; - expect(testState.workers).toHaveLength(0); - expect(testState.pools.size).toBe(0); - await act(async () => button.click()); - await view.render(renderViews(1)); - const pool = [...testState.pools.keys()][0]!; - await act(async () => testState.pools.get(pool)); - expect(pool.isInitialized()).toBe(true); - expect(testState.workers).toHaveLength(2); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); - expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0); - - await view.render(renderViews(2)); - await act(async () => pool.primeFileHighlightCache(files[1]!)); - expect(testState.pools.size).toBe(1); - expect(testState.workers).toHaveLength(2); - expect(new Set(testState.renderPools)).toEqual(new Set([pool])); - expect(pool.getFileResultCache(files[1]!)).toBeDefined(); - await vi.waitFor(() => expect(codeFile("answer.ts")).toContain("answer")); - - await view.render(renderViews(1)); - expect(pool.getStats().totalWorkers).toBe(2); - expect(testState.terminations).toBe(0); - await view.render(renderViews(0)); - await vi.advanceTimersByTimeAsync(29_999); - expect(pool.getStats().totalWorkers).toBe(2); - expect(testState.terminations).toBe(0); - - await view.render(renderViews(1)); - expect(testState.pools.size).toBe(1); - expect(testState.workers).toHaveLength(2); - expect(loadingViews()).toBe(0); - expect(pool.getFileResultCache(files[1]!)).toBeDefined(); - await vi.advanceTimersByTimeAsync(30_000); - expect(pool.getStats().totalWorkers).toBe(2); - - await view.render(renderViews(0)); - await vi.advanceTimersByTimeAsync(30_000); - expect(pool.getStats().totalWorkers).toBe(0); - expect(testState.terminations).toBe(2); - - await view.render(renderViews(1)); - const reopenedPool = [...testState.pools.keys()][1]!; - await act(async () => testState.pools.get(reopenedPool)); - expect(reopenedPool.isInitialized()).toBe(true); - expect(testState.workers).toHaveLength(4); - expect(document.querySelector("button")).toBe(button); - expect(button.textContent).toBe("Edited draft"); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); - }); - - it("renders through Pierre's main-thread fallback when worker creation fails", async () => { - testState.failWorkers = true; - await mount(renderViews(1)).initial; - const pool = [...testState.pools.keys()][0]!; - await act(async () => { - await expect(testState.pools.get(pool)).rejects.toMatchObject({ _tag: "DiffWorkerError" }); - await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["text"], - preferredHighlighter: "shiki-wasm", - }); - }); - expect(pool.isWorkingPool()).toBe(false); - expect(testState.workers).toHaveLength(0); - await vi.waitFor(() => expect(codeFile("notes.txt")).toContain("Plain text is ready.")); - }); - - it("keeps an initializing pool until its last view closes and ignores late responses", async () => { - testState.holdInitialization = true; - const held = new Promise((resolve) => { - testState.onInitializationHeld = resolve; - }); - const view = mount(renderViews(2)); - await view.initial; - const pool = [...testState.pools.keys()][0]!; - const pending = testState.pools.get(pool)!; - await held; - expect(pool.isInitialized()).toBe(false); - expect(testState.pools.size).toBe(1); - expect(loadingViews()).toBe(2); - expect(testState.renderPools).toHaveLength(0); - - await view.render(renderViews(1)); - expect(pool.getStats().totalWorkers).toBe(2); - expect(testState.terminations).toBe(0); - await view.render(renderViews(0)); - // Pierre gives up on a held startup after 10 seconds; the idle timer then tears the pool down. - const timedOut = expect(pending).rejects.toThrow("worker initialization timed out"); - await vi.advanceTimersByTimeAsync(30_000); - await timedOut; - await act(async () => { - for (const deliver of testState.heldResponses) deliver(); - }); - expect(pool.getStats().totalWorkers).toBe(0); - expect(testState.workers).toHaveLength(2); - expect(testState.terminations).toBe(2); - expect(testState.renderPools).toHaveLength(0); - expect(document.querySelectorAll("pre")).toHaveLength(0); - }); -}); - -describe("collapsed diff content", () => { - let pool: WorkerPoolManager; - let container: HTMLDivElement; - let viewer: FileDiff | CodeView | undefined; - - const loadedDiff = () => { - const parsed = getRenderablePatch( - [ - "diff --git a/before.ts b/after.ts", - "similarity index 50%", - "rename from before.ts", - "rename to after.ts", - "--- a/before.ts", - "+++ b/after.ts", - "@@ -1,2 +1,2 @@", - " const shared = 1;", - "-const before = 2;", - "+const after = 3;", - ].join("\n"), - "collapsed-content", - ); - if (parsed?.kind !== "files" || !parsed.files[0]) throw new Error("Expected diff"); - return parsed.files[0]; - }; - const code = () => - [...container.querySelectorAll("diffs-container")] - .flatMap((element) => [...(element.shadowRoot?.querySelectorAll("[data-line]") ?? [])]) - .map((line) => line.textContent) - .join("\n"); - - beforeEach(async () => { - testState.requests = []; - testState.failWorkers = false; - testState.holdInitialization = false; - pool = new WorkerPoolManager( - { workerFactory: () => new DiffsWorker(), poolSize: 1 }, - { theme: "pierre-dark", langs: ["typescript"], preferredHighlighter: "shiki-wasm" }, - ); - await pool.initialize(); - container = document.createElement("div"); - container.style.cssText = "height: 400px; width: 800px; overflow: auto"; - document.body.append(container); - }); - - afterEach(() => { - viewer?.cleanUp(); - viewer = undefined; - pool.terminate(); - container.remove(); - vi.restoreAllMocks(); - }); - - it("defers plain AST and highlight work across collapsed renders until expansion", async () => { - const fileDiff = loadedDiff(); - const highlight = vi.spyOn(pool, "highlightDiffAST"); - const plainAST = vi.spyOn(pool, "getPlainDiffAST"); - const options = { theme: "pierre-dark", collapsed: true, disableErrorHandling: true } as const; - const file = new FileDiff(options, pool); - viewer = file; - file.render({ fileDiff, containerWrapper: container }); - file.render({ fileDiff, forceRender: true }); - expect.soft(highlight.mock.calls.length).toBe(0); - expect.soft(plainAST.mock.calls.length).toBe(0); - expect(code()).toBe(""); - expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( - "after.ts", - ); - - file.setOptions({ ...options, collapsed: false }); - file.render({ fileDiff, forceRender: true }); - expect(highlight).toHaveBeenCalled(); - await pool.primeDiffHighlightCache(fileDiff); - await vi.waitFor(() => expect(code()).toContain("const after = 3;")); - expect(code()).toContain("const before = 2;"); - expect(testState.requests.filter((request) => request.type === "diff")).toHaveLength(1); - }); - - it("finds newly loaded collapsed lines and unfolds the match with its annotation and header", async () => { - const fileDiff = loadedDiff(); - const item: CodeViewItem = { - type: "diff", - id: "renamed", - fileDiff, - collapsed: true, - annotations: [{ side: "additions", lineNumber: 2, metadata: "Review this change" }], - }; - const view = new CodeView( - { - theme: "pierre-dark", - disableErrorHandling: true, - onRevealSearchMatch: (match) => - view.setItems([{ ...match, collapsed: false, version: (match.version ?? 0) + 1 }]), - renderHeaderMetadata: (file) => { - if (!("hunks" in file)) return; - const metadata = document.createElement("span"); - const { additions, deletions } = getDiffLineStat([file]); - metadata.textContent = "+" + additions + " / -" + deletions; - return metadata; - }, - renderAnnotation: (annotation) => { - const note = document.createElement("span"); - note.textContent = annotation.metadata ?? ""; - return note; - }, - }, - pool, - ); - viewer = view; - view.setup(container); - view.openSearch(); - const search = container.querySelector("[data-diffs-search]")!.shadowRoot!; - const input = search.querySelector("input[data-search]")!; - input.value = "const after"; - input.dispatchEvent(new Event("input", { bubbles: true })); - view.setItems([item]); - view.render(true); - expect(code()).toBe(""); - expect(search.textContent).toContain("1 results"); - expect(container.textContent).toContain("+1 / -1"); - - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - view.render(true); - await vi.waitFor(() => expect(code()).toContain("const after = 3;")); - expect(search.textContent).toContain("1 of 1"); - expect(container.textContent).toContain("Review this change"); - expect(container.textContent).toContain("+1 / -1"); - expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( - "after.ts", - ); - - await pool.primeDiffHighlightCache(fileDiff); - view.render(true); - const highlight = vi.spyOn(pool, "highlightDiffAST"); - const plainAST = vi.spyOn(pool, "getPlainDiffAST"); - const updatedDiff = { ...fileDiff, name: "updated.ts", cacheKey: "collapsed-update" }; - view.setItems([{ ...item, fileDiff: updatedDiff, version: 2 }]); - view.render(true); - expect(highlight).not.toHaveBeenCalled(); - expect(plainAST).not.toHaveBeenCalled(); - expect(code()).toBe(""); - expect(search.textContent).toContain("1 of 1"); - expect(container.querySelector("diffs-container")?.shadowRoot?.textContent).toContain( - "updated.ts", - ); - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - view.render(true); - await vi.waitFor(() => expect(code()).toContain("const after = 3;")); - expect(container.textContent).toContain("Review this change"); - }); -}); - -type RevealHandle = NonNullable[0]>; -const revealCalls: { - scope: string; - fileKey: string; - mounted: boolean; - collapsed: boolean | undefined; -}[] = []; - -function RevealViewer({ - viewerRef, - scope, - collapsed, -}: { - viewerRef: Ref; - scope: string; - collapsed: boolean; -}) { - const instance = useRef<{ collapsed: boolean } | undefined>(undefined); - useLayoutEffect(() => { - instance.current = { collapsed }; - return () => { - instance.current = undefined; - }; - }, [collapsed]); - useImperativeHandle( - viewerRef, - () => ({ - getInstance: () => instance.current, - scrollTo: (target: CodeViewScrollTarget) => { - if (target.type !== "item") return; - revealCalls.push({ - scope, - fileKey: target.id, - mounted: instance.current !== undefined, - collapsed: instance.current?.collapsed, - }); - }, - }), - [scope], - ); - return null; -} - -function RevealViews({ - scope, - visible, - selectedFile = null, - requestId = 0, -}: { - scope: string; - visible: boolean; - selectedFile?: string | null; - requestId?: number; -}) { - const [viewer, setViewer] = useState(null); - const [collapsed, setCollapsed] = useState(true); - // The reveal hook cancels pending clicks by scope identity, so a scope object - // must only be replaced when one of its inputs changes. - const [storedRevealScope, setRevealScope] = useState(() => ({ - scope, - selectedRequest: { selectedFile, requestId }, - })); - let revealScope = storedRevealScope; - if ( - revealScope.scope !== scope || - revealScope.selectedRequest.selectedFile !== selectedFile || - revealScope.selectedRequest.requestId !== requestId - ) { - revealScope = { - scope, - selectedRequest: - revealScope.selectedRequest.selectedFile === selectedFile && - revealScope.selectedRequest.requestId === requestId - ? revealScope.selectedRequest - : { selectedFile, requestId }, - }; - setRevealScope(revealScope); - } - const { selectedRequest } = revealScope; - useEffect(() => { - if (!selectedRequest.selectedFile || !viewer?.getInstance()) return; - viewer.scrollTo({ type: "item", id: selectedRequest.selectedFile, align: "start" }); - }, [selectedRequest, viewer]); - const requestReveal = useCodeViewFileReveal(viewer, revealScope); - return ( - <> - {visible && ( - - )} - - - ); -} - -describe("code-view file reveals", () => { - let view: ReturnType; - const reveal = () => act(async () => document.querySelector("button")!.click()); - - beforeEach(() => { - revealCalls.length = 0; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - }); - - afterEach(async () => { - await unmountAll(); - vi.unstubAllGlobals(); - }); - - it("does not replay a handled tree click or scroll an old handle when a selected file remounts the view", async () => { - view = mount(); - await view.initial; - await reveal(); - expect(revealCalls.at(-1)?.fileKey).toBe("tree-file"); - revealCalls.length = 0; - - await view.render( - , - ); - expect(revealCalls).toEqual([ - { scope: "second", fileKey: "external-b", mounted: true, collapsed: false }, - ]); - }); - - it("waits for a live viewer and applies each pending tree click only once", async () => { - view = mount(); - await view.initial; - await reveal(); - expect(revealCalls).toEqual([]); - - await view.render(); - expect(revealCalls.map((call) => call.fileKey)).toEqual(["external", "tree-file"]); - revealCalls.length = 0; - await view.render(); - await view.render(); - expect(revealCalls).toEqual([ - { scope: "diff", fileKey: "external", mounted: true, collapsed: false }, - ]); - - revealCalls.length = 0; - // A click that lands in the same update that hides the viewer waits for it to return. - await act(async () => { - document.querySelector("button")!.click(); - view.root.render(); - }); - expect(revealCalls).toEqual([]); - await view.render(); - expect(revealCalls).toEqual([ - { scope: "diff", fileKey: "external", mounted: true, collapsed: false }, - { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, - ]); - }); - - it("cancels pending tree clicks when an external selection or diff scope changes", async () => { - view = mount(); - await view.initial; - await reveal(); - await view.render( - , - ); - expect(revealCalls.map((call) => call.fileKey)).toEqual(["external-b"]); - revealCalls.length = 0; - - await view.render(); - await reveal(); - await view.render(); - await view.render(); - expect(revealCalls).toEqual([]); - }); - - it("reveals after a collapsed file expands and honors another click on the same file", async () => { - view = mount(); - await view.initial; - await reveal(); - await reveal(); - expect(revealCalls).toEqual([ - { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, - { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false }, - ]); - }); -}); diff --git a/apps/web/src/components/diffs/codeViewSearch.test.ts b/apps/web/src/components/diffs/codeViewSearch.test.ts index 80af9f5ed2f9..ee377043900c 100644 --- a/apps/web/src/components/diffs/codeViewSearch.test.ts +++ b/apps/web/src/components/diffs/codeViewSearch.test.ts @@ -63,44 +63,4 @@ describe("collectMatches", () => { expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]); expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]); }); - - it("honours case, whole-word, and regex toggles", () => { - expect(find("Name", { caseSensitive: true })).toEqual([ - "file-0:deletions:2:9-13", - "file-0:additions:2:9-13", - "file-0:additions:3:17-21", - "file-0:deletions:21:6-10", - ]); - expect(find("name", { wholeWord: true })).toEqual([ - "file-0:additions:21:6-10", - "file-0:deletions:21:6-10", - ]); - expect(find("n[a-z]+e", { regex: true })).toHaveLength(6); - }); - - it("marks unchanged lines, which split view draws in both columns", () => { - const flags = collectMatches(items, { - text: "ctx", - caseSensitive: false, - wholeWord: false, - regex: false, - }).map(({ lineNumber, isContext }) => [lineNumber, isContext]); - expect(flags).toEqual([ - [21, true], - [23, true], - ]); - expect( - collectMatches(items, { - text: "oldName", - caseSensitive: false, - wholeWord: false, - regex: false, - }).map(({ isContext }) => isContext), - ).toEqual([false, false]); - }); - - it("returns nothing for an empty query or an invalid pattern", () => { - expect(find("")).toEqual([]); - expect(find("(", { regex: true })).toEqual([]); - }); }); diff --git a/apps/web/src/components/diffs/commentSubmitShortcut.test.ts b/apps/web/src/components/diffs/commentSubmitShortcut.test.ts deleted file mode 100644 index 434228db57ed..000000000000 --- a/apps/web/src/components/diffs/commentSubmitShortcut.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isCommentSubmitShortcut } from "./commentSubmitShortcut"; - -describe("isCommentSubmitShortcut", () => { - it("accepts Command or Ctrl+Enter only while an eligible comment is idle", () => { - expect( - isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, "Looks good", false), - ).toBe(true); - expect( - isCommentSubmitShortcut({ key: "Enter", metaKey: false, ctrlKey: true }, "Looks good", false), - ).toBe(true); - expect( - isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, "Looks good", true), - ).toBe(false); - }); - - it("rejects empty comments and unrelated key presses", () => { - expect( - isCommentSubmitShortcut({ key: "Enter", metaKey: true, ctrlKey: false }, " ", false), - ).toBe(false); - expect( - isCommentSubmitShortcut({ key: "K", metaKey: true, ctrlKey: false }, "Looks good", false), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index 170e84308069..757ab3f83de4 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -1,39 +1,12 @@ import type { FileDiffMetadata } from "@pierre/diffs"; -import { preloadFileTree } from "@pierre/trees"; import { describe, expect, it } from "vite-plus/test"; -import { - buildDiffFileTreeUpdates, - compareDiffFileTreeEntries, - collectDirectoryPaths, - diffFileTreePositions, - diffFileTreeEntries, -} from "./diffFileTree.logic"; +import { buildDiffFileTreeUpdates, diffFileTreeEntries } from "./diffFileTree.logic"; function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata { return { type, name, prevName } as FileDiffMetadata; } -describe("diffFileTreeEntries", () => { - it("maps each change type to its git status under the file's current path", () => { - expect( - diffFileTreeEntries([ - file("new", "a/src/a.ts"), - file("deleted", "src/b.ts"), - file("rename-pure", "src/c.ts", "src/old-c.ts"), - file("rename-changed", "src/d.ts", "src/old-d.ts"), - file("change", "README.md"), - ]), - ).toEqual([ - { path: "a/src/a.ts", status: "added" }, - { path: "src/b.ts", status: "deleted" }, - { path: "src/c.ts", status: "renamed" }, - { path: "src/d.ts", status: "renamed" }, - { path: "README.md", status: "modified" }, - ]); - }); -}); - describe("diffFileTreeEntries", () => { it("folds a file-to-symlink type change into one modified entry", () => { expect( @@ -51,44 +24,6 @@ describe("diffFileTreeEntries", () => { }); }); -describe("collectDirectoryPaths", () => { - it("lists every ancestor once, parents first, with Pierre's trailing slash", () => { - expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([ - "apps/", - "apps/web/", - "apps/web/src/", - ]); - }); -}); - -describe("diff tree reading order", () => { - it("places folders and files where their first diff appears", () => { - const paths = [ - "apps/mobile/src/state/shell.ts", - "apps/mobile/src/features/threads/route.ts", - "apps/mobile/src/features/threads/screen.tsx", - ]; - const positions = diffFileTreePositions(paths); - const tree = preloadFileTree({ - paths, - initialExpansion: "open", - flattenEmptyDirectories: true, - sort: compareDiffFileTreeEntries(() => positions), - }); - const rows = [...tree.shadowHtml.matchAll(/data-item-path="([^"]+)"/g)].map( - (match) => match[1], - ); - expect(rows).toEqual([ - "apps/mobile/src/", - "apps/mobile/src/state/", - "apps/mobile/src/state/shell.ts", - "apps/mobile/src/features/threads/", - "apps/mobile/src/features/threads/route.ts", - "apps/mobile/src/features/threads/screen.tsx", - ]); - }); -}); - describe("buildDiffFileTreeUpdates", () => { it("adds a new file's directories before the file", () => { expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ @@ -104,14 +39,4 @@ describe("buildDiffFileTreeUpdates", () => { { type: "remove", path: "src/lib/", recursive: true }, ]); }); - - it("keeps a directory that still holds a file", () => { - expect(buildDiffFileTreeUpdates(["src/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([ - { type: "remove", path: "src/a.ts" }, - ]); - }); - - it("produces nothing when the paths are unchanged", () => { - expect(buildDiffFileTreeUpdates(["src/a.ts"], ["src/a.ts"])).toEqual([]); - }); }); diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx deleted file mode 100644 index 3ae2c3d998a8..000000000000 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const { refresh } = vi.hoisted(() => ({ refresh: vi.fn<() => Promise>() })); - -vi.mock(import("~/assets/assetUrls"), async (original) => ({ - ...(await original()), - useAssetUrlRefresh: () => refresh, -})); -// The real source view highlights through a worker pool; the text it receives is what matters here. -// Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. -vi.mock(import("./ReadOnlySourcePreview"), () => ({ - default: ({ text }: { text: string }) =>
{text}
, -})); - -import { AttachmentFilePreview } from "./AttachmentFilePreview"; - -describe("attachment HTML preview recovery", () => { - const originalUrl = "data:text/html,original"; - const renewedUrl = "data:text/html,renewed"; - let now = 0; - let root: Root; - let container: HTMLDivElement; - - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - now = 0; - vi.spyOn(Date, "now").mockImplementation(() => now); - refresh.mockReset().mockResolvedValueOnce(originalUrl).mockResolvedValue(renewedUrl); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("

Captured HTML

")), - ); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - const openRemote = () => - act(async () => - root.render( - , - ), - ); - - const toggleMode = async (label: string) => { - const button = container.querySelector(`button[aria-label="${label}"]`); - expect(button, label).not.toBeNull(); - await act(async () => button!.click()); - }; - const alert = () => container.querySelector('[role="alert"]'); - - it("keeps a renewed URL for subsequent rendered and source views", async () => { - await openRemote(); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe(originalUrl); - now = 61 * 60_000; - await toggleMode("Show HTML source"); - await act(async () => { - await import("./ReadOnlySourcePreview"); - }); - expect(fetch).toHaveBeenCalledExactlyOnceWith(renewedUrl, expect.any(Object)); - await vi.waitFor(() => - expect(container.querySelector("pre")?.textContent).toBe("

Captured HTML

"), - ); - - await toggleMode("Show rendered page"); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe(renewedUrl); - await toggleMode("Show HTML source"); - expect(refresh).toHaveBeenCalledTimes(2); - expect(vi.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([renewedUrl, renewedUrl]); - }); - - it("does not fetch or mark an expired URL fresh when reauthorization is unavailable", async () => { - await openRemote(); - now = 61 * 60_000; - refresh.mockResolvedValue(null); - await toggleMode("Show HTML source"); - expect(fetch).not.toHaveBeenCalled(); - expect(alert()?.textContent).toContain("Reconnect to the environment and try again."); - - await toggleMode("Show rendered page"); - await toggleMode("Show HTML source"); - expect(refresh).toHaveBeenCalledTimes(3); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("can return to rendered HTML after local source decoding fails", async () => { - const file = new Blob([new Uint8Array([0x3c, 0x70, 0x3e, 0xe9])], { type: "text/html" }); - await act(async () => - root.render( - , - ), - ); - await toggleMode("Show HTML source"); - await vi.waitFor(() => expect(alert()?.textContent).toContain("not UTF-8")); - - await toggleMode("Show rendered page"); - expect(alert()).toBeNull(); - expect(container.querySelector("iframe")?.title).toBe("document.html"); - }); -}); diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx deleted file mode 100644 index 5f91fb96ad71..000000000000 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, onTestFinished, vi } from "vite-plus/test"; - -import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; - -it("keeps the loaded HTML page while its signed URL renews, and reloads on a new revision", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - onTestFinished(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - const render = (src: string, documentKey?: string) => - act(async () => - root.render( - , - ), - ); - - await render("about:blank#token-a", "review.html:1"); - const frame = container.querySelector("iframe"); - await render("about:blank#token-b", "review.html:1"); - expect(container.querySelector("iframe")).toBe(frame); - expect(frame?.getAttribute("src")).toBe("about:blank#token-a"); - - await render("about:blank#token-c", "review.html:2"); - expect(container.querySelector("iframe")).not.toBe(frame); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe("about:blank#token-c"); - - await render("about:blank#first"); - const urlDrivenFrame = container.querySelector("iframe"); - await render("about:blank#second"); - expect(container.querySelector("iframe")).not.toBe(urlDrivenFrame); - expect(container.querySelector("iframe")?.getAttribute("src")).toBe("about:blank#second"); -}); diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx deleted file mode 100644 index 118d1cf0321b..000000000000 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import "../../index.css"; - -import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import FileBrowserPanel from "./FileBrowserPanel"; - -const files = vi.hoisted(() => ({ - directoryEntries: [] as ProjectEntry[], - directoryReady: true, - directoryPending: false, - searchEntries: [] as ProjectEntry[], - searchPending: false, - searchError: null as string | null, - loadingDirectories: new Set() as ReadonlySet, - load: vi.fn(async (_directoryPath: string) => {}), - refresh: vi.fn(), -})); - -vi.mock("./useDirectoryEntries", () => ({ - useDirectoryEntries: () => ({ - entries: files.directoryEntries, - loadingDirectories: files.loadingDirectories, - load: files.load, - refresh: files.refresh, - ready: files.directoryReady, - error: null, - isPending: files.directoryPending, - }), -})); -vi.mock("~/state/queries", () => ({ - useProjectPathSearch: () => ({ - entries: files.searchEntries, - isPending: files.searchPending, - error: files.searchError, - truncated: false, - refresh: files.refresh, - }), -})); -vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); -vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: vi.fn() })); -vi.mock("~/composerHandleContext", () => ({ useComposerHandleContext: () => null })); -vi.mock("~/fileContextMenu", () => ({ useFileContextMenu: () => ({}) })); -vi.mock("~/localApi", () => ({ readLocalApi: () => undefined })); -vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); -vi.mock("~/components/MorphIcon", () => ({ MorphIcon: () => null })); -vi.mock("~/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: ReactNode }) => children, - TooltipTrigger: ({ render }: { render: ReactNode }) => render, - TooltipPopup: () => null, -})); - -describe("Files panel", () => { - let root: Root | undefined; - let container: HTMLDivElement | undefined; - const props = { - environmentId: EnvironmentId.make("test-environment"), - cwd: "/workspace", - projectName: "Test project", - selectedPath: null, - selectedPathRevealId: 0, - onOpenFile: vi.fn(), - workspaceMutationId: null, - }; - - async function render() { - if (!container) { - container = document.body.appendChild(document.createElement("div")); - // The tree virtualizes its rows, so it needs a real viewport to render any. - container.style.cssText = - "display: flex; flex-direction: column; width: 320px; height: 480px;"; - root = createRoot(container); - } - await act(async () => root!.render()); - } - - function searchInput() { - const input = container!.querySelector('input[type="search"]'); - expect(input).not.toBeNull(); - return input!; - } - - async function search(value: string) { - const input = searchInput(); - // React tracks the last value it saw on the element, so set it through the native setter. - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); - await act(async () => { - input.dispatchEvent(new Event("input", { bubbles: true })); - }); - } - - function visiblePaths() { - const tree = container!.querySelector("file-tree-container"); - if (!tree) return []; - return [...tree.shadowRoot!.querySelectorAll("[data-item-path]")].map((row) => - row.getAttribute("data-item-path"), - ); - } - - function hasMessage(message: string) { - return [...container!.querySelectorAll("div")].some( - (node) => node.childNodes.length === 1 && node.textContent === message, - ); - } - - function expectVisibleLoadingStatus() { - const status = container!.querySelector('[role="status"]'); - expect(status?.textContent).toBe("Loading files…"); - expect(status!.checkVisibility()).toBe(true); - const bounds = status!.getBoundingClientRect(); - expect(bounds.width).toBeGreaterThan(1); - expect(bounds.height).toBeGreaterThan(1); - } - - function rowTop(path: string) { - const row = container! - .querySelector("file-tree-container") - ?.shadowRoot!.querySelector(`[data-item-path="${CSS.escape(path)}"]`); - expect(row, `visible row ${path}`).not.toBeNull(); - const bounds = row!.getBoundingClientRect(); - expect(bounds.height).toBeGreaterThan(0); - return bounds.top; - } - - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - files.directoryEntries = [ - { path: "src", kind: "directory" }, - { path: "src/app.ts", kind: "file" }, - { path: "package.json", kind: "file" }, - ]; - files.directoryReady = true; - files.directoryPending = false; - files.searchEntries = []; - files.searchPending = false; - files.searchError = null; - }); - - afterEach(async () => { - await act(async () => root?.unmount()); - container?.remove(); - root = undefined; - container = undefined; - vi.unstubAllGlobals(); - }); - - it("keeps visible rows stationary during loading while showing initial loading status", async () => { - const entries = files.directoryEntries; - files.directoryEntries = []; - files.directoryReady = false; - files.directoryPending = true; - await render(); - expect(visiblePaths()).toEqual([]); - expectVisibleLoadingStatus(); - - files.directoryEntries = entries; - files.directoryReady = true; - files.directoryPending = false; - await render(); - expect(visiblePaths()).toEqual(["src/", "package.json"]); - const initialTop = rowTop("package.json"); - - files.directoryPending = true; - await render(); - expect(rowTop("package.json")).toBe(initialTop); - - files.directoryPending = false; - await search("pack"); - expect(visiblePaths()).toEqual(["package.json"]); - const filteredTop = rowTop("package.json"); - - files.searchPending = true; - await search("package"); - expect(visiblePaths()).toEqual(["package.json"]); - expect(rowTop("package.json")).toBe(filteredTop); - - files.searchPending = false; - await render(); - expect(rowTop("package.json")).toBe(filteredTop); - }); - - it("shows no matching files instead of the full tree when a filename is absent", async () => { - await render(); - expect(visiblePaths()).toEqual(["src/", "package.json"]); - - await search(".env.local"); - expect(visiblePaths()).toEqual([]); - expect(hasMessage("No matching files.")).toBe(true); - - await search("package"); - expect(visiblePaths()).toEqual(["package.json"]); - expect(hasMessage("No matching files.")).toBe(false); - - await search(""); - expect(visiblePaths()).toEqual(["src/", "package.json"]); - }); - - it("still finds ignored files already loaded from a directory", async () => { - files.directoryEntries.push({ path: ".env.local", kind: "file", ignored: true }); - await render(); - await search(".env.local"); - expect(visiblePaths()).toEqual([".env.local"]); - expect(hasMessage("No matching files.")).toBe(false); - }); - - it("waits for remote matches without showing unrelated files or a premature empty result", async () => { - await render(); - files.searchPending = true; - await search("settings.ts"); - expect(visiblePaths()).toEqual([]); - expectVisibleLoadingStatus(); - expect(hasMessage("No matching files.")).toBe(false); - - files.searchEntries = [{ path: "config/settings.ts", kind: "file" }]; - files.searchPending = false; - await render(); - expect(visiblePaths()).toEqual(["config/", "config/settings.ts"]); - expect(hasMessage("No matching files.")).toBe(false); - }); - - it("restores the tree on Escape after an unsuccessful search", async () => { - await render(); - await search("missing-file"); - expect(visiblePaths()).toEqual([]); - - const input = searchInput(); - input.focus(); - await act(async () => { - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - }); - expect(document.activeElement).not.toBe(input); - expect(visiblePaths()).toEqual(["src/", "package.json"]); - expect(hasMessage("No matching files.")).toBe(false); - }); - - it("does not report an empty result when the search failed", async () => { - await render(); - files.searchError = "Workspace search failed."; - await search("missing-file"); - expect(visiblePaths()).toEqual([]); - expect(hasMessage("No matching files.")).toBe(false); - expect(container!.textContent).toContain("Workspace search failed."); - }); - - describe("expand all", () => { - const workspace: Record = { - src: [ - { path: "src/lib", kind: "directory" }, - { path: "src/index.ts", kind: "file" }, - ], - "src/lib": [{ path: "src/lib/util.ts", kind: "file" }], - node_modules: [ - { path: "node_modules/react", kind: "directory", ignored: true }, - { path: "node_modules/.modules.yaml", kind: "file", ignored: true }, - ], - "node_modules/react": [{ path: "node_modules/react/index.js", kind: "file", ignored: true }], - }; - const listed = () => files.load.mock.calls.map(([path]) => path); - - // Answer each listing the panel asks for, as the server would, until it asks for no more. - async function answerListings() { - const answered = new Set(); - for (;;) { - const path = listed().find((requested) => !answered.has(requested)); - if (path === undefined) return; - answered.add(path); - files.directoryEntries = [...files.directoryEntries, ...(workspace[path] ?? [])]; - await render(); - } - } - - function button(label: "Expand all folders" | "Collapse all folders") { - const match = container!.querySelector(`button[aria-label="${label}"]`); - expect(match, label).not.toBeNull(); - return match!; - } - - async function clickRow(path: string) { - const row = container! - .querySelector("file-tree-container")! - .shadowRoot!.querySelector(`[data-item-path="${path}"]`); - expect(row, path).not.toBeNull(); - await act(async () => row!.click()); - await answerListings(); - } - - beforeEach(() => { - files.load.mockClear(); - files.directoryEntries = [ - { path: "src", kind: "directory" }, - { path: "node_modules", kind: "directory", ignored: true }, - { path: "README.md", kind: "file" }, - ]; - }); - - it("opens every folder except ignored ones, which it never lists", async () => { - await render(); - await act(async () => button("Expand all folders").click()); - await answerListings(); - - expect(listed().toSorted()).toEqual(["src", "src/lib"]); - expect(visiblePaths()).toContain("src/lib/util.ts"); - expect(visiblePaths()).not.toContain("node_modules/.modules.yaml"); - button("Collapse all folders"); - - // Collapsing a folder by hand ends expand all; reopening it counts as all expanded again, - // with the ignored folder still closed. - await clickRow("src/lib/"); - button("Expand all folders"); - await clickRow("src/lib/"); - button("Collapse all folders"); - }); - - it("opens an ignored folder by hand without walking into it, and collapses it with the rest", async () => { - await render(); - await act(async () => button("Expand all folders").click()); - await answerListings(); - - await clickRow("node_modules/"); - expect(listed()).toContain("node_modules"); - expect(listed()).not.toContain("node_modules/react"); - expect(visiblePaths()).toContain("node_modules/react/"); - expect(visiblePaths()).not.toContain("node_modules/react/index.js"); - - await act(async () => button("Collapse all folders").click()); - expect(visiblePaths()).toEqual(["node_modules/", "src/", "README.md"]); - }); - }); -}); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 95ab6387b3d8..e8aee4c3ecb0 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -3,14 +3,9 @@ import { ProjectReadFileError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { - formatFileCommentRange, - normalizeFileCommentRange, - remapFileCommentAnnotations, -} from "./fileCommentAnnotations"; +import { remapFileCommentAnnotations } from "./fileCommentAnnotations"; import { filePreviewReadErrorMessage, - isMarkdownPreviewFile, resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -20,38 +15,7 @@ import { const decodeReadError = Schema.decodeSync(ProjectReadFileError); describe("file preview read errors", () => { - it.each([ - ["path_not_file", "The path is a directory or special file, not a regular file."], - ["binary_file", "The file is binary and cannot be displayed as text."], - ["workspace_path_outside_root", "The requested path is outside the workspace."], - ["resolved_path_outside_root", "The path resolves to a location outside the workspace."], - [ - "operation_failed", - "The file could not be accessed or read. It may be missing or inaccessible.", - ], - ] as const)("describes %s without revealing the platform cause", (failure, message) => { - const error = new ProjectReadFileError({ - cwd: "/workspace", - relativePath: "workspace/outline.md", - failure, - operation: "realpath-target", - resolvedPath: "/workspace/workspace/outline.md", - cause: new Error("EACCES: sensitive platform detail"), - }); - expect(filePreviewReadErrorMessage(error)).toBe(message); - }); - - it("distinguishes an inaccessible workspace from an inaccessible file", () => { - const error = new ProjectReadFileError({ - cwd: "/workspace", - relativePath: "outline.md", - failure: "operation_failed", - operation: "realpath-workspace-root", - operationPath: "/workspace", - }); - expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed."); - }); - + // regression: #15628 read errors from servers without attempted paths keep their own message it("preserves the public message from older servers", () => { const error = decodeReadError({ _tag: "ProjectReadFileError", @@ -62,15 +26,6 @@ describe("file preview read errors", () => { }); describe("file comment annotations", () => { - it("normalizes and formats selected line ranges", () => { - expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ - startLine: 7, - endLine: 16, - }); - expect(formatFileCommentRange(7, 7)).toBe("L7"); - expect(formatFileCommentRange(7, 16)).toBe("L7 to L16"); - }); - it("keeps an annotation range attached when Pierre remaps its anchor line", () => { expect( remapFileCommentAnnotations([ @@ -108,61 +63,6 @@ describe("file comment annotations", () => { }); }); -describe("isMarkdownPreviewFile", () => { - it("recognizes markdown and MDX files case-insensitively", () => { - expect(isMarkdownPreviewFile("README.md")).toBe(true); - expect(isMarkdownPreviewFile("docs/guide.MDX")).toBe(true); - }); - - it("does not treat other text files as markdown", () => { - expect(isMarkdownPreviewFile("docs/guide.txt")).toBe(false); - expect(isMarkdownPreviewFile("docs/markdown.ts")).toBe(false); - }); -}); - -describe("shouldShowFileExplorer", () => { - it("hides the workspace tree for host files and attachments", () => { - expect( - shouldShowFileExplorer({ - relativePath: "/tmp/report.pdf", - explorerOpen: true, - attachmentOpen: false, - }), - ).toBe(false); - expect( - shouldShowFileExplorer({ - relativePath: "~/.claude/CLAUDE.md", - explorerOpen: true, - attachmentOpen: false, - }), - ).toBe(false); - expect( - shouldShowFileExplorer({ - relativePath: "report.pdf", - explorerOpen: true, - attachmentOpen: true, - }), - ).toBe(false); - }); - - it("keeps the saved explorer preference for workspace files", () => { - expect( - shouldShowFileExplorer({ - relativePath: "docs/report.pdf", - explorerOpen: true, - attachmentOpen: false, - }), - ).toBe(true); - expect( - shouldShowFileExplorer({ - relativePath: "docs/report.pdf", - explorerOpen: false, - attachmentOpen: false, - }), - ).toBe(false); - }); -}); - describe("setMarkdownTaskChecked", () => { const markdown = "- [ ] First\n- [x] Second\n"; @@ -210,14 +110,6 @@ describe("workspaceAssetResource", () => { absolutePath: "/repo/docs/index.html", }; - it("names the thread once the server knows it", () => { - expect(workspaceAssetResource({ ...input, draft: false })).toEqual({ - _tag: "workspace-file", - threadId: "thread-1", - path: "/repo/docs/index.html", - }); - }); - it("names the workspace root for a draft, which the server cannot resolve from a thread", () => { expect(workspaceAssetResource({ ...input, draft: true })).toEqual({ _tag: "draft-workspace-file", diff --git a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts index 2693ac9a0729..f112ba97ab5d 100644 --- a/apps/web/src/components/files/ProjectFilePicker.logic.test.ts +++ b/apps/web/src/components/files/ProjectFilePicker.logic.test.ts @@ -6,43 +6,7 @@ function pathsForQuery(entries: Parameters[0 return getProjectFilePickerMatches(entries, query).map(({ name, path }) => ({ name, path })); } -const entries = [ - { kind: "directory", path: "apps/web/src" }, - { kind: "file", path: "apps/web/src/index.ts" }, - { kind: "file", path: "packages/shared/src/index.ts" }, - { kind: "file", path: "README.md" }, - { kind: "file", path: ".gitignore" }, -] as const; - describe("getProjectFilePickerMatches", () => { - it("returns files only and preserves index order for an empty query", () => { - assert.deepEqual(pathsForQuery(entries, ""), [ - { name: "index.ts", path: "apps/web/src/index.ts" }, - { name: "index.ts", path: "packages/shared/src/index.ts" }, - { name: "README.md", path: "README.md" }, - { name: ".gitignore", path: ".gitignore" }, - ]); - }); - - it("preserves the server result order", () => { - assert.deepEqual(pathsForQuery(entries, "index"), [ - { name: "index.ts", path: "apps/web/src/index.ts" }, - { name: "index.ts", path: "packages/shared/src/index.ts" }, - { name: "README.md", path: "README.md" }, - { name: ".gitignore", path: ".gitignore" }, - ]); - }); - - it("supports space-separated path tokens and a result limit", () => { - assert.deepEqual( - getProjectFilePickerMatches(entries, "src index", 1).map(({ name, path }) => ({ - name, - path, - })), - [{ name: "index.ts", path: "apps/web/src/index.ts" }], - ); - }); - it("matches ordered characters while allowing skipped characters", () => { const fuzzyEntries = [ { kind: "file", path: "src/TestFlags.tsx" }, @@ -66,13 +30,6 @@ describe("getProjectFilePickerMatches", () => { }); }); - it("uses the first ordered subsequence for highlighting", () => { - assert.deepEqual( - getProjectFilePickerMatches([{ kind: "file", path: "aabba" }], "aba")[0]?.nameMatchIndices, - [0, 2, 4], - ); - }); - it("normalizes path prefixes consistently with server search", () => { assert.deepEqual( getProjectFilePickerMatches([{ kind: "file", path: "src/index.ts" }], "@/src")[0] diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts index ec33bda933e5..6152ac94f0d7 100644 --- a/apps/web/src/components/files/fileEditorVirtualization.test.ts +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -90,21 +90,6 @@ class RecordedResizeObserver { } } -function drainRenderFrames() { - const errors = vi.spyOn(console, "error"); - try { - for (let frame = 0; animationFrames.size > 0; frame += 1) { - if (frame === 10) throw new Error("The production render queue did not settle"); - const callbacks = [...animationFrames.values()]; - animationFrames.clear(); - for (const callback of callbacks) callback(frame); - } - expect(errors).not.toHaveBeenCalled(); - } finally { - errors.mockRestore(); - } -} - function measuredElement(element: MeasuredElement): HTMLElement { return element as unknown as HTMLElement; } @@ -362,6 +347,7 @@ describe("wrapped editor document changes", () => { expect(instance.getLinePosition(6001)?.top).toBe(before!.top); }); + // regression: #10018 retaining wrapped row heights must not shift rows in an unwrapped layout it("keeps unwrapped positions unchanged", async () => { const { instance, append } = await makeFixture("scroll"); const before = instance.getLinePosition(6001); @@ -397,341 +383,3 @@ describe("wrapped editor document changes", () => { expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); }); }); - -describe("wrapped measurement widths", () => { - it.each([ - [226.25, 482.25], - [482.25, 226.25], - ])( - "drops offscreen measurements when content changes from %spx to %spx", - async (before, after) => { - const { instance } = await makeFixture("wrap", 6001, before); - instance.measure([[6000, 60]], after); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(120)).toBe(20); - expect(instance.getLineHeight(6000)).toBe(60); - expect(instance.getVirtualizedHeight()).toBe(120076); - }, - ); - - it.each([ - [226.25, 482.25], - [482.25, 226.25], - ])( - "does not retain old-width prefix heights after a %spx to %spx resize and edit", - async (before, after) => { - const { instance, append } = await makeFixture("wrap", 6001, before); - instance.measure([[6000, 60]], after); - append(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(120)).toBe(20); - expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); - }, - ); - - it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { - const { instance, append } = await makeFixture(); - instance.measure([[6000, 60]]); - const deliverResize = instance.observeLayout(); - instance.resizeContent(482.25); - const readsBeforeEdit = MeasuredElement.geometryReads; - append(); - expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); - expect(instance.getLineHeight(0)).toBe(80); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); - }); - - it("keeps measured prefixes through same-width reconciliation and editing", async () => { - const { instance, append } = await makeFixture(); - instance.measure([[6000, 60]]); - append(); - expect(instance.getLineHeight(0)).toBe(80); - expect(instance.getLineHeight(120)).toBe(60); - expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); - }); - - it("preserves measurements on first and repeated same-width resize deliveries", async () => { - const { instance } = await makeFixture(); - const before = instance.getVirtualizedHeight(); - const deliverResize = instance.observeLayout(); - deliverResize(); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(80); - expect(instance.getVirtualizedHeight()).toBe(before); - }); - - it.each([ - [226.25, 482.25], - [482.25, 226.25], - ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { - const { instance } = await makeFixture("wrap", 6001, before); - instance.measure([[6000, 20]], before); - const deliverResize = instance.observeLayout(); - instance.resizeContent(after); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); - }); - - it("keeps width validity when a new editor attaches to the same file", async () => { - const { instance } = await makeFixture(); - instance.measure([[6000, 20]]); - const deliverResize = instance.observeLayout(); - vi.stubGlobal("SVGSVGElement", EditorElement); - vi.stubGlobal( - "document", - Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), - ); - const first = new Editor("file"); - editors.push(first); - first.edit(instance); - first.cleanUp(); - const second = new Editor("file"); - editors.push(second); - second.edit(instance); - instance.resizeContent(482.25); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); - }); - - it("ignores stale resize deliveries after cleanup", async () => { - const { instance } = await makeFixture(); - const deliverResize = instance.observeLayout(); - instance.dispose(); - clearRenderQueue(); - animationFrames.clear(); - deliverResize(); - expect(animationFrames.size).toBe(0); - }); - - it("does not discard a stable code width for gutter subpixel rounding", async () => { - const { instance } = await makeFixture("wrap", 6001, 482.25); - const deliverResize = instance.observeLayout(); - instance.resizeContent(482.234375, 539); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(80); - }); - - it("waits for a visible width instead of caching measurements while hidden", async () => { - const { instance } = await makeFixture(); - instance.measure([[6000, 20]]); - const deliverResize = instance.observeLayout(); - instance.resizeContent(0, 0); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(80); - instance.resizeContent(482.25); - deliverResize(); - drainRenderFrames(); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getVirtualizedHeight()).toBe(120036); - }); -}); - -class EditorElement extends MeasuredElement { - style: Record = {}; - parentElement: EditorElement | null = null; - - override appendChild(child: EditorElement) { - child.parentElement = this; - this.children.push(child); - return child; - } - - append(child: EditorElement) { - this.appendChild(child); - } - - prepend(child: EditorElement) { - child.parentElement = this; - this.children.unshift(child); - } - - replaceChildren(...children: (EditorElement | string)[]) { - this.children = []; - for (const child of children) if (typeof child !== "string") this.appendChild(child); - } - - setAttribute() {} - removeAttribute() {} - addEventListener() {} - removeEventListener() {} - after() {} - - remove() { - if (this.parentElement) { - this.parentElement.children = this.parentElement.children.filter((child) => child !== this); - } - } - - set innerHTML(value: string) { - expect(value.startsWith(" "code" in child.dataset); - } - - querySelector(selector: string) { - expect(selector).toBe("[data-deletions]"); - return null; - } - - getContext() { - return { measureText: (text: string) => ({ width: text.length * 8 }) }; - } -} - -async function makeEditorFixture(lineCount: number) { - const { instance, file } = await makeFixture("wrap", lineCount); - vi.stubGlobal("SVGSVGElement", EditorElement); - vi.stubGlobal("Document", EditorElement); - vi.stubGlobal( - "document", - Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), - ); - vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); - vi.stubGlobal("requestAnimationFrame", () => 1); - vi.stubGlobal("cancelAnimationFrame", () => {}); - vi.stubGlobal("getComputedStyle", () => ({ - paddingTop: "0px", - fontSize: "13px", - fontFamily: "monospace", - tabSize: "2", - lineHeight: "20px", - })); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - disconnect() {} - }, - ); - instance.setOptions({ - ...instance.options, - useTokenTransformer: true, - controlledSelection: true, - themeType: "dark", - }); - const content = new EditorElement(); - content.dataset.content = ""; - const gutter = new EditorElement(); - gutter.dataset.gutter = ""; - const code = new EditorElement(); - code.dataset.code = ""; - code.appendChild(gutter); - code.appendChild(content); - const shadow = new EditorElement(); - shadow.appendChild(code); - const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); - const highlighter = await getSharedHighlighter({ - themes: ["pierre-dark"], - langs: ["text"], - preferredHighlighter: "shiki-wasm", - }); - const editor = new Editor("file"); - editors.push(editor); - editor.edit(instance); - editor.__syncRenderView({ - highlighter, - fileContainer: measuredElement(host), - file, - lineAnnotations: undefined, - renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 }, - }); - const append = (count: number) => { - const lines = editor.getText().split("\n"); - const end = { line: lines.length - 1, character: lines.at(-1)!.length }; - editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); - }; - const remove = (count: number) => { - const lines = editor.getText().split("\n"); - const startLine = lines.length - count - 1; - editor.applyEdits([ - { - range: { - start: { line: startLine, character: lines[startLine]!.length }, - end: { line: lines.length - 1, character: lines.at(-1)!.length }, - }, - newText: "", - }, - ]); - }; - return { instance, editor, append, remove }; -} - -describe("editor gutter-width changes", () => { - it.each([ - [9999, 1], - [9998, 3], - ])( - "clears prefix measurements when %i lines grow by %i across a digit boundary", - async (lines, count) => { - const { instance, editor, append } = await makeEditorFixture(lines); - expect(instance.getLineHeight(0)).toBe(80); - append(count); - expect(editor.getText().split("\n")).toHaveLength(lines + count); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(5000)).toBe(20); - }, - ); - - it.each([ - [10000, 1], - [10002, 4], - ])( - "clears prefix measurements when %i lines shrink by %i across a digit boundary", - async (lines, count) => { - const { instance, editor, remove } = await makeEditorFixture(lines); - remove(count); - expect(editor.getText().split("\n")).toHaveLength(lines - count); - expect(instance.getLineHeight(0)).toBe(20); - expect(instance.getLineHeight(5000)).toBe(20); - }, - ); - - it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { - const { instance, editor, append } = await makeEditorFixture(9999); - append(1); - instance.measure([[0, 100]]); - editor.undo(); - expect(editor.getText().split("\n")).toHaveLength(9999); - expect(instance.getLineHeight(0)).toBe(20); - instance.measure([[0, 80]]); - editor.redo(); - expect(editor.getText().split("\n")).toHaveLength(10000); - expect(instance.getLineHeight(0)).toBe(20); - }); - - it.each([ - [9998, 1], - [10000, 2], - ])( - "retains prefix measurements when %i lines grow by %i without changing digit width", - async (lines, count) => { - const { instance, editor, append } = await makeEditorFixture(lines); - append(count); - expect(editor.getText().split("\n")).toHaveLength(lines + count); - expect(instance.getLineHeight(0)).toBe(80); - expect(instance.getLineHeight(5000)).toBe(80); - editor.undo(); - expect(instance.getLineHeight(0)).toBe(80); - editor.redo(); - expect(instance.getLineHeight(0)).toBe(80); - }, - ); -}); diff --git a/apps/web/src/components/files/fileLineReveal.test.ts b/apps/web/src/components/files/fileLineReveal.test.ts index 3a63168d5d3b..2b01a8b27487 100644 --- a/apps/web/src/components/files/fileLineReveal.test.ts +++ b/apps/web/src/components/files/fileLineReveal.test.ts @@ -3,19 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; describe("resolveCenteredFileLineScrollTop", () => { - it("centers an estimated virtualized line position", () => { - expect( - resolveCenteredFileLineScrollTop({ - scrollTop: 0, - scrollHeight: 2_000, - viewportTop: 100, - viewportHeight: 400, - fileTop: 20, - estimatedLine: { top: 1_000, height: 20 }, - }), - ).toBe(830); - }); - it("corrects a stale estimate from the rendered line geometry", () => { expect( resolveCenteredFileLineScrollTop({ diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 9dc20199aab1..26ba99ebcbd0 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,35 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { - breadcrumbPathOf, - fileBreadcrumbChildren, - fileBreadcrumbParent, - fileBreadcrumbs, - isListableHostFolder, - joinHostPath, - retainedBreadcrumbTrail, - trailChildOf, -} from "./filePath"; +import { fileBreadcrumbChildren, fileBreadcrumbs, retainedBreadcrumbTrail } from "./filePath"; describe("fileBreadcrumbs", () => { - it("builds project, directory, and file crumbs", () => { - expect(fileBreadcrumbs("t3code", "apps/web/src/main.tsx")).toEqual([ - { label: "t3code", path: "", kind: "project" }, - { label: "apps", path: "apps", kind: "directory" }, - { label: "web", path: "apps/web", kind: "directory" }, - { label: "src", path: "apps/web/src", kind: "directory" }, - { label: "main.tsx", path: "apps/web/src/main.tsx", kind: "file" }, - ]); - }); - - it("normalizes repeated separators", () => { - expect(fileBreadcrumbs("workspace", "src//index.ts").map((crumb) => crumb.label)).toEqual([ - "workspace", - "src", - "index.ts", - ]); - }); - it("starts host paths outside the workspace at the filesystem root", () => { expect(fileBreadcrumbs("t3code", "/tmp/t3-cleanup/report.md")).toEqual([ { label: "tmp", path: "/tmp", kind: "directory" }, @@ -47,31 +20,6 @@ describe("fileBreadcrumbs", () => { "\\\\server\\share\\report.md", ]); }); - - it("builds home-relative breadcrumbs with navigable child folders", () => { - expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([ - { label: "~", path: "~", kind: "directory" }, - { label: ".claude", path: "~/.claude", kind: "directory" }, - { label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" }, - ]); - expect(isListableHostFolder("~/.claude")).toBe(true); - }); -}); - -describe("breadcrumbPathOf", () => { - it.each([ - ["", ""], - ["src", "src"], - ["src/", "src"], - ["src//components", "src/components"], - ["/tmp/t3-demo/", "/tmp/t3-demo"], - ["/", "/"], - ["C:\\Users\\me\\", "C:\\Users\\me"], - ["C:\\", "C:\\"], - ["\\\\server\\share\\", "\\\\server\\share"], - ])("writes %j as its crumb names it", (path, expected) => { - expect(breadcrumbPathOf(path)).toBe(expected); - }); }); describe("fileBreadcrumbChildren", () => { @@ -86,38 +34,12 @@ describe("fileBreadcrumbChildren", () => { { path: "src-old/index.ts", kind: "file" as const }, ]; - it("returns only the immediate children of the project root", () => { - expect(fileBreadcrumbChildren(entries, "")).toEqual([ - { path: "src", kind: "directory", label: "src" }, - { path: "src-old", kind: "directory", label: "src-old" }, - { path: "README.md", kind: "file", label: "README.md" }, - ]); - }); - it("honors segment boundaries and sorts folders before files", () => { expect(fileBreadcrumbChildren(entries, "src")).toEqual([ { path: "src/lib", kind: "directory", label: "lib" }, { path: "src/index.ts", kind: "file", label: "index.ts" }, ]); }); - - it("uses natural file-name ordering and preserves input order for equivalent names", () => { - const files = ["file10.ts", "File2.ts", "file02.ts", "file2.ts"].map((name) => ({ - path: `src/lib/${name}`, - kind: "file" as const, - })); - - expect(fileBreadcrumbChildren(files, "src/lib").map((entry) => entry.label)).toEqual([ - "File2.ts", - "file02.ts", - "file2.ts", - "file10.ts", - ]); - }); - - it("returns an empty list for an empty or missing directory", () => { - expect(fileBreadcrumbChildren(entries, "missing")).toEqual([]); - }); }); describe("retainedBreadcrumbTrail", () => { @@ -146,56 +68,4 @@ describe("retainedBreadcrumbTrail", () => { ])("from trail %j to %j keeps %j", (trail, path, expected) => { expect(retainedBreadcrumbTrail(trail, path)).toBe(expected); }); - - it("starts at the path when nothing came before", () => { - expect(retainedBreadcrumbTrail(undefined, "apps")).toBe("apps"); - }); -}); - -describe("trailChildOf", () => { - it.each([ - ["/Users/me", "/Users/me/projects/scope", "projects"], - ["/Users/me/projects/scope", "/Users/me/projects/scope", null], - ["/Users/me", "/Users/meg/a", null], - ["C:\\", "C:\\Users\\me", "Users"], - ["C:\\Users", "C:\\Users\\me\\scope", "me"], - ["\\\\nas\\share", "\\\\nas\\share\\docs\\notes", "docs"], - ])("in %j toward %j selects %j", (folder, trail, expected) => { - expect(trailChildOf(folder, trail)).toBe(expected); - }); -}); - -describe("joinHostPath", () => { - it.each([ - ["/Users/me", "projects/scope", "/Users/me/projects/scope"], - ["/", "Users", "/Users"], - ["C:\\", "Users/me", "C:\\Users\\me"], - ["C:\\Users", "me", "C:\\Users\\me"], - ["\\\\nas\\share", "docs/notes.md", "\\\\nas\\share\\docs\\notes.md"], - ])("joins %j and %j as %j", (folder, relativePath, expected) => { - expect(joinHostPath(folder, relativePath)).toBe(expected); - }); -}); - -describe("isListableHostFolder", () => { - it.each([ - ["/Users", true], - ["C:\\", true], - ["\\\\nas\\share", true], - // A UNC server is not a directory the server can list. - ["\\\\nas", false], - ["apps/web", false], - ])("%j is %j", (path, expected) => { - expect(isListableHostFolder(path)).toBe(expected); - }); -}); - -describe("fileBreadcrumbParent", () => { - it.each([ - ["src/lib", "src"], - ["src", ""], - ["", null], - ])("returns the parent of %j", (path, expected) => { - expect(fileBreadcrumbParent(path)).toBe(expected); - }); }); diff --git a/apps/web/src/components/files/fileSaveCoordinator.test.ts b/apps/web/src/components/files/fileSaveCoordinator.test.ts index 9871553c81f5..f0db93144fb9 100644 --- a/apps/web/src/components/files/fileSaveCoordinator.test.ts +++ b/apps/web/src/components/files/fileSaveCoordinator.test.ts @@ -18,33 +18,6 @@ describe("FileSaveCoordinator", () => { vi.useRealTimers(); }); - it("debounces edits and persists only the latest contents", async () => { - vi.useFakeTimers(); - const persist = vi - .fn<(contents: string) => Promise>>() - .mockResolvedValue(AsyncResult.success(undefined)); - const onPendingChange = vi.fn(); - const onConfirmed = vi.fn(); - const coordinator = new FileSaveCoordinator({ - debounceMs: 500, - persist, - onPendingChange, - onConfirmed, - }); - - coordinator.change("first"); - await vi.advanceTimersByTimeAsync(300); - coordinator.change("latest"); - await vi.advanceTimersByTimeAsync(499); - expect(persist).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(persist).toHaveBeenCalledOnce(); - expect(persist).toHaveBeenCalledWith("latest"); - expect(onConfirmed).toHaveBeenCalledWith("latest"); - expect(onPendingChange.mock.calls).toEqual([[true], [true], [false]]); - }); - it("keeps pending state until an edit made during a write is also saved", async () => { vi.useFakeTimers(); const firstWrite = deferred(); @@ -143,29 +116,6 @@ describe("FileSaveCoordinator", () => { expect(persist).toHaveBeenLastCalledWith("latest"); }); - it("does not rewrite a write that lands while the editor closes", async () => { - vi.useFakeTimers(); - const inFlight = deferred(); - const persist = vi - .fn<(contents: string) => Promise>>() - .mockReturnValueOnce(inFlight.promise) - .mockResolvedValue(AsyncResult.success(undefined)); - const coordinator = new FileSaveCoordinator({ - debounceMs: 500, - persist, - onPendingChange: vi.fn(), - onConfirmed: vi.fn(), - }); - - coordinator.change("only"); - await vi.advanceTimersByTimeAsync(500); - coordinator.dispose(); - inFlight.resolve(AsyncResult.success(undefined)); - await vi.runAllTimersAsync(); - - expect(persist).toHaveBeenCalledOnce(); - }); - it("retries a failed write when the editor closes", async () => { vi.useFakeTimers(); const persist = vi @@ -229,28 +179,4 @@ describe("FileSaveCoordinator", () => { expect(persist).not.toHaveBeenCalled(); expect(onPendingChange).not.toHaveBeenCalled(); }); - - it("does not persist confirmed contents again on disposal", async () => { - vi.useFakeTimers(); - const persist = vi - .fn<(contents: string) => Promise>>() - .mockResolvedValue(AsyncResult.success(undefined)); - const coordinator = new FileSaveCoordinator({ - debounceMs: 500, - persist, - onPendingChange: vi.fn(), - onConfirmed: vi.fn(), - }); - - coordinator.change("temporary edit"); - await vi.advanceTimersByTimeAsync(500); - coordinator.change("original contents"); - await vi.advanceTimersByTimeAsync(500); - expect(persist).toHaveBeenCalledTimes(2); - - coordinator.dispose(); - await vi.runAllTimersAsync(); - - expect(persist).toHaveBeenCalledTimes(2); - }); }); diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts index 7ff400f5d03e..2c459a4c3d9a 100644 --- a/apps/web/src/components/files/fileTreeDragMention.test.ts +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -28,28 +28,6 @@ describe("createFileTreeDragMentionController", () => { expect(controller.isDragInProgress()).toBe(true); }); - it("strips the trailing slash from directory rows", () => { - const controller = createFileTreeDragMentionController({}); - const transfer = makeTransfer(); - controller.handleDragStart({ - dataTransfer: transfer, - composedPath: () => [rowNode("docs/architecture/")], - }); - expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[architecture](docs/architecture)"); - }); - - it("mentions rows of a host folder by their host path", () => { - const controller = createFileTreeDragMentionController({ - mentionPath: (path) => `/Users/me/scope/${path}`, - }); - const transfer = makeTransfer(); - controller.handleDragStart({ - dataTransfer: transfer, - composedPath: () => [rowNode("notes/")], - }); - expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[notes](/Users/me/scope/notes)"); - }); - it("does not tag drags of selected text from the panel chrome", () => { const controller = createFileTreeDragMentionController({}); const transfer = makeTransfer("selected text"); @@ -58,28 +36,6 @@ describe("createFileTreeDragMentionController", () => { expect(controller.isDragInProgress()).toBe(false); }); - it("ignores drags that carry no row path", () => { - const controller = createFileTreeDragMentionController({}); - const transfer = makeTransfer(); - controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); - expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); - expect(controller.isDragInProgress()).toBe(false); - }); - - it("deselects the dragged row exactly once when the drag ends", () => { - const deselected: Array = []; - const controller = createFileTreeDragMentionController({}); - const deselect = (path: string) => void deselected.push(path); - controller.handleDragStart({ - dataTransfer: makeTransfer(), - composedPath: () => [rowNode("src/app.ts")], - }); - controller.handleDragEnd(deselect); - controller.handleDragEnd(deselect); - expect(deselected).toEqual(["src/app.ts"]); - expect(controller.isDragInProgress()).toBe(false); - }); - it("drags the whole selection when the dragged row is part of it", () => { const deselected: Array = []; const controller = createFileTreeDragMentionController({}); @@ -96,23 +52,4 @@ describe("createFileTreeDragMentionController", () => { controller.handleDragEnd(deselect); expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]); }); - - it("drags only the row under the cursor when it is outside the selection", () => { - const controller = createFileTreeDragMentionController({}); - controller.handleSelectionChange(["docs/index.md"]); - const transfer = makeTransfer(); - controller.handleDragStart({ - dataTransfer: transfer, - composedPath: () => [rowNode("src/app.ts")], - }); - expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)"); - }); - - it("does not deselect anything when no drag was started", () => { - const deselected: Array = []; - const controller = createFileTreeDragMentionController({}); - const deselect = (path: string) => void deselected.push(path); - controller.handleDragEnd(deselect); - expect(deselected).toEqual([]); - }); }); diff --git a/apps/web/src/components/files/fileTreeExpansion.test.ts b/apps/web/src/components/files/fileTreeExpansion.test.ts deleted file mode 100644 index 1fba6957728f..000000000000 --- a/apps/web/src/components/files/fileTreeExpansion.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from "@effect/vitest"; - -import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; - -type FakeDirectoryItem = { - isDirectory: () => true; - isExpanded: () => boolean; - expand: () => void; - collapse: () => void; -}; - -function makeModel(expanded: Record) { - const items = new Map(); - return { - getItem: (path: string) => { - const existing = items.get(path); - if (existing !== undefined) return existing; - const item: FakeDirectoryItem = { - isDirectory: () => true, - isExpanded: () => expanded[path] ?? false, - expand: () => { - expanded[path] = true; - }, - collapse: () => { - expanded[path] = false; - }, - }; - items.set(path, item); - return item; - }, - }; -} - -describe("file tree expansion", () => { - it("requires at least one directory and detects whether all are expanded", () => { - const model = makeModel({ "src/": true, "test/": true }); - expect(areAllDirectoriesExpanded(model, [])).toBe(false); - expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); - expect( - areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), - ).toBe(false); - }); - - it("expands and collapses every directory", () => { - const expanded = { "src/": true, "test/": false }; - const model = makeModel(expanded); - setAllDirectoriesExpanded(model, ["src/", "test/"], true); - expect(expanded).toEqual({ "src/": true, "test/": true }); - setAllDirectoriesExpanded(model, ["src/", "test/"], false); - expect(expanded).toEqual({ "src/": false, "test/": false }); - }); - - it("skips directories already at the requested state", () => { - const model = makeModel({ "src/": true }); - const item = model.getItem("src/"); - const collapse = vi.spyOn(item, "collapse"); - setAllDirectoriesExpanded(model, ["src/"], true); - expect(collapse).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/components/files/fileTreePathReconciliation.test.ts b/apps/web/src/components/files/fileTreePathReconciliation.test.ts deleted file mode 100644 index ce7779796e7c..000000000000 --- a/apps/web/src/components/files/fileTreePathReconciliation.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; - -describe("buildFileTreePathUpdates", () => { - it("updates only paths that changed", () => { - expect( - buildFileTreePathUpdates( - ["src/", "src/kept.ts", "src/removed.ts"], - ["src/", "src/kept.ts", "src/added.ts"], - ), - ).toEqual([ - { type: "remove", path: "src/removed.ts" }, - { type: "add", path: "src/added.ts" }, - ]); - }); - - it("removes a missing subtree with one recursive update", () => { - expect( - buildFileTreePathUpdates( - ["src/", "src/feature/", "src/feature/index.ts", "src/kept.ts"], - ["src/", "src/kept.ts"], - ), - ).toEqual([{ type: "remove", path: "src/feature/", recursive: true }]); - }); - - it("does nothing when a refresh returns the same tree", () => { - const paths = ["src/", "src/index.ts"]; - expect(buildFileTreePathUpdates(paths, [...paths])).toEqual([]); - }); -}); diff --git a/apps/web/src/components/files/loadFileAncestors.test.ts b/apps/web/src/components/files/loadFileAncestors.test.ts index 1fd165a790ed..d817f84d29d2 100644 --- a/apps/web/src/components/files/loadFileAncestors.test.ts +++ b/apps/web/src/components/files/loadFileAncestors.test.ts @@ -11,19 +11,6 @@ function queuedLoader() { return { paths, resolve, load }; } -it("bounds loads to four and finishes children even when parents finish last", async () => { - const queue = queuedLoader(); - const loading = loadFileAncestors("a/b/c/d/e/f/file.ts", queue.load, new AbortController()); - for (let index = 3; index < 6; index++) { - queue.resolve[index]?.(); - await Promise.resolve(); - expect(queue.paths).toHaveLength(index + 2); - } - expect(queue.paths).toEqual(["", "a", "a/b", "a/b/c", "a/b/c/d", "a/b/c/d/e", "a/b/c/d/e/f"]); - for (const resolve of queue.resolve) resolve(); - await loading; -}); - it("stops aborted reveals while a new selection loads independently", async () => { const old = queuedLoader(); const controller = new AbortController(); diff --git a/apps/web/src/components/files/projectFilesQueryState.test.ts b/apps/web/src/components/files/projectFilesQueryState.test.ts index feff1ecfdeaf..0de9b327b896 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.ts +++ b/apps/web/src/components/files/projectFilesQueryState.test.ts @@ -98,19 +98,6 @@ describe("project files queries", () => { expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); }); - it("releases a retained unsaved draft when explicitly cleared", () => { - setProjectFileQueryData(environmentId, "/repo", "convex.json", "first draft"); - setProjectFileQueryData(environmentId, "/repo", "convex.json", "latest draft"); - drainRegistryTasks(); - expect(getUnsavedProjectFileQueryData(environmentId, "/repo", "convex.json")?.contents).toBe( - "latest draft", - ); - - clearProjectFileQueryData(environmentId, "/repo", "convex.json"); - drainRegistryTasks(); - expect(appAtomRegistry.getNodes().has(optimisticFile)).toBe(false); - }); - it("keeps a reopened editor's newer draft pending when the old editor's write finishes", async () => { vi.stubGlobal("window", {}); vi.useFakeTimers(); diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx index 3688848460d2..6f6fbece92e9 100644 --- a/apps/web/src/components/files/projectFilesQueryState.test.tsx +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -2,14 +2,12 @@ import { AuthFilesystemReadScope, EnvironmentId, type AuthSessionState, - type ProjectListEntriesResult, ProjectReadFileError, type ProjectReadFileResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -100,11 +98,9 @@ vi.mock("~/state/queries", () => ({ })); import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; -import { useT3ProjectFileState } from "~/hooks/useT3ProjectFileScripts"; import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; const environmentId = EnvironmentId.make("environment-1"); -const decodeReadError = Schema.decodeSync(ProjectReadFileError); function deferred() { let resolve!: (value: A) => void; @@ -123,13 +119,6 @@ function file(contents: string): ProjectReadFileResult { }; } -function projectEntries(paths: readonly string[]): ProjectListEntriesResult { - return { - entries: paths.map((path) => ({ path, kind: "file" })), - truncated: false, - }; -} - async function flushEffects(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -172,145 +161,6 @@ describe("project query refresh", () => { } }); - it("keeps t3.json and the file tree loading until the file grant arrives", () => { - authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); - const registry = AtomRegistry.make(); - atomHooks.registry = registry; - const config = { - defaultThreadEnvMode: "worktree", - scripts: [{ name: "Test", command: "vp test" }], - }; - projectMocks.readFile.mockReturnValue( - Atom.make(AsyncResult.success(file(JSON.stringify(config)))), - ); - projectMocks.listEntries.mockReturnValue( - Atom.make(AsyncResult.success(projectEntries(["t3.json"]))), - ); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - try { - expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ - data: null, - error: null, - isPending: true, - }); - expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ - data: null, - error: null, - isPending: true, - }); - expect(useT3ProjectFileState(environmentId, "/repo").status).toBe("loading"); - expect(projectMocks.readFile).not.toHaveBeenCalled(); - expect(projectMocks.listEntries).not.toHaveBeenCalled(); - - authorizationMocks.sessionAtom = Atom.make( - AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }), - ); - expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ - status: "valid", - file: config, - scripts: config.scripts, - }); - expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject({ - data: projectEntries(["t3.json"]), - error: null, - isPending: false, - }); - } finally { - registry.dispose(); - atomHooks.registry = null; - } - }); - - it.each(["connected", "offline"] as const)( - "preserves cached t3.json defaults and scripts during a granted refresh while %s", - (phase) => { - authorizationMocks.phase = phase; - authorizationMocks.sessionAtom = Atom.make( - AsyncResult.success( - { authenticated: true, scopes: [AuthFilesystemReadScope] }, - { waiting: true }, - ), - ); - const registry = AtomRegistry.make(); - atomHooks.registry = registry; - const config = { - defaultThreadEnvMode: "worktree", - scripts: [{ name: "Test", command: "vp test" }], - }; - projectMocks.readFile.mockReturnValue( - Atom.make(AsyncResult.success(file(JSON.stringify(config)), { waiting: true })), - ); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - try { - expect(useProjectFileQuery(environmentId, "/repo", "t3.json")).toMatchObject({ - error: null, - isPending: true, - }); - expect(useT3ProjectFileState(environmentId, "/repo")).toEqual({ - status: "valid", - file: config, - scripts: config.scripts, - }); - } finally { - registry.dispose(); - atomHooks.registry = null; - } - }, - ); - - it.each([ - { phase: "connected", sessionError: "The session request timed out." }, - { phase: "offline", sessionError: null }, - ] as const)( - "reports unavailable file access for $phase connections", - ({ phase, sessionError }) => { - authorizationMocks.phase = phase; - authorizationMocks.sessionAtom = Atom.make( - sessionError === null - ? AsyncResult.initial() - : AsyncResult.failure(Cause.fail(new Error(sessionError))), - ); - const registry = AtomRegistry.make(); - atomHooks.registry = registry; - projectMocks.optimisticFile.mockReturnValue(Atom.make({ data: file("cached contents") })); - try { - const unavailable = { - data: null, - error: sessionError ?? "This environment is not connected.", - isPending: false, - }; - expect(useProjectFileQuery(environmentId, "/repo", "src/preview.ts")).toMatchObject( - unavailable, - ); - expect(useProjectEntriesQuery(environmentId, "/repo")).toMatchObject(unavailable); - expect(projectMocks.readFile).not.toHaveBeenCalled(); - expect(projectMocks.listEntries).not.toHaveBeenCalled(); - } finally { - registry.dispose(); - atomHooks.registry = null; - } - }, - ); - - it("leaves disabled file queries idle while the file grant loads", () => { - authorizationMocks.sessionAtom = Atom.make(AsyncResult.initial()); - const registry = AtomRegistry.make(); - atomHooks.registry = registry; - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - try { - expect(useProjectFileQuery(environmentId, "/repo", "t3.json", false)).toMatchObject({ - data: null, - error: null, - isPending: false, - }); - expect(useT3ProjectFileState(environmentId, null).status).toBe("missing"); - expect(projectMocks.readFile).not.toHaveBeenCalled(); - } finally { - registry.dispose(); - atomHooks.registry = null; - } - }); - it("replaces an in-flight initial read when a workspace mutation arrives", async () => { const requests: Array>> = []; const readAtom = Atom.make( @@ -364,149 +214,6 @@ describe("project query refresh", () => { } }); - it("revalidates cached entries when a workspace mutation is observed after mounting", async () => { - const requests: Array>> = []; - const entriesAtom = Atom.make( - Effect.promise(() => { - const request = deferred(); - requests.push(request); - return request.promise; - }), - ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true })); - const registry = AtomRegistry.make(); - const unmount = registry.mount(entriesAtom); - projectMocks.listEntries.mockReturnValue(entriesAtom); - atomHooks.registry = registry; - let renderedPaths: readonly string[] = []; - - const render = (mutationId: string | null) => { - reactHooks.beginRender(); - const query = useProjectEntriesQuery(environmentId, "/repo"); - renderedPaths = query.data?.entries.map((entry) => entry.path) ?? []; - useWorkspaceMutationRefresh({ - mutationId, - refresh: query.refresh, - resourceKey: "files:environment-1:/repo", - }); - }; - - try { - await flushEffects(); - expect(requests).toHaveLength(1); - requests[0]!.resolve(projectEntries(["src/old.ts"])); - await flushEffects(); - - render("mutation-1"); - expect(renderedPaths).toEqual(["src/old.ts"]); - await flushEffects(); - expect(requests).toHaveLength(2); - - requests[1]!.resolve(projectEntries(["src/new.ts"])); - await flushEffects(); - render("mutation-1"); - expect(renderedPaths).toEqual(["src/new.ts"]); - expect(requests).toHaveLength(2); - } finally { - unmount(); - registry.dispose(); - atomHooks.registry = null; - } - }); - - it("does not issue a file read for a disabled image preview", async () => { - const requests: Array>> = []; - const readAtom = Atom.make( - Effect.promise(() => { - const request = deferred(); - requests.push(request); - return request.promise; - }), - ); - const registry = AtomRegistry.make(); - projectMocks.readFile.mockReturnValue(readAtom); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - atomHooks.registry = registry; - - try { - reactHooks.beginRender(); - const query = useProjectFileQuery(environmentId, "/repo", "preview.png", false); - useWorkspaceMutationRefresh({ - enabled: false, - mutationId: "mutation-1", - refresh: query.refresh, - resourceKey: "file:environment-1:/repo:preview.png", - }); - await flushEffects(); - - expect(projectMocks.readFile).not.toHaveBeenCalled(); - expect(query.readError).toBeNull(); - expect(requests).toHaveLength(0); - } finally { - registry.dispose(); - atomHooks.registry = null; - } - }); - - it("reports a directory named like an image as not a file", async () => { - const readAtom = Atom.make( - Effect.fail( - new ProjectReadFileError({ - cwd: "/repo", - relativePath: "assets.png", - failure: "path_not_file", - }), - ), - ); - const registry = AtomRegistry.make(); - const unmount = registry.mount(readAtom); - projectMocks.readFile.mockReturnValue(readAtom); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - atomHooks.registry = registry; - - try { - await flushEffects(); - reactHooks.beginRender(); - const query = useProjectFileQuery(environmentId, "/repo", "assets.png"); - expect(query.isNotFile).toBe(true); - expect(query.readError?.failure).toBe("path_not_file"); - expect(query.data).toBeNull(); - } finally { - unmount(); - registry.dispose(); - atomHooks.registry = null; - } - }); - - it("reports a directory read as not a file", async () => { - const readAtom = Atom.make( - Effect.fail( - new ProjectReadFileError({ - cwd: "/repo", - relativePath: ".agents/skills", - failure: "path_not_file", - }), - ), - ); - const registry = AtomRegistry.make(); - const unmount = registry.mount(readAtom); - projectMocks.readFile.mockReturnValue(readAtom); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - atomHooks.registry = registry; - - try { - await flushEffects(); - reactHooks.beginRender(); - const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills"); - expect(query.isNotFile).toBe(true); - expect(query.readError?.failure).toBe("path_not_file"); - expect(query.data).toBeNull(); - } finally { - unmount(); - registry.dispose(); - atomHooks.registry = null; - } - }); - it("confirms files from reads and preserves that identity until a definitive folder result", () => { const contents = file("# readme"); const success = AsyncResult.success(contents); @@ -551,37 +258,4 @@ describe("project query refresh", () => { atomHooks.registry = null; } }); - - it("retains decoded read failure context for the connected environment", async () => { - const error = decodeReadError({ - _tag: "ProjectReadFileError", - cwd: "C:\\workspace", - relativePath: "workspace/outline.md", - failure: "operation_failed", - operation: "realpath-target", - operationPath: "C:\\workspace\\workspace\\outline.md", - resolvedPath: "C:\\workspace\\workspace\\outline.md", - message: "Failed to read workspace file.", - }); - const readAtom = Atom.make(Effect.fail(error)); - const registry = AtomRegistry.make(); - const unmount = registry.mount(readAtom); - projectMocks.readFile.mockReturnValue(readAtom); - projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); - atomHooks.registry = registry; - - try { - await flushEffects(); - reactHooks.beginRender(); - const query = useProjectFileQuery(environmentId, "C:\\workspace", "workspace/outline.md"); - expect(query.readError).toBe(error); - expect(query.error).toBe(error.message); - expect(query.isNotFile).toBe(false); - expect(query.data).toBeNull(); - } finally { - unmount(); - registry.dispose(); - atomHooks.registry = null; - } - }); }); diff --git a/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx b/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx index 7d8e230b56df..b04512fa9f9d 100644 --- a/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx +++ b/apps/web/src/components/files/useFileSaveCoordinator.browser.test.tsx @@ -1,8 +1,8 @@ +import { render, fakeTimers } from "../../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, useEffect } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; const { writeFile, confirmFile, readScope, getUnsavedFile } = vi.hoisted(() => ({ writeFile: vi.fn(), @@ -25,7 +25,6 @@ vi.mock(import("./projectFilesQueryState"), async (original) => ({ getUnsavedProjectFileQueryData: getUnsavedFile, })); -import { setMarkdownTaskChecked } from "./filePreviewMode"; import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; const environmentId = EnvironmentId.make("save-lifecycle-audit"); @@ -36,59 +35,43 @@ const defaultProps = { relativePath: "file.txt", onPendingChange, }; -let root: Root | null; -// The editor's change callback from the latest commit, as an editor would hold it. -let latestChange: (contents: string) => void; - -function FileSurface(props: Parameters[0]) { - const coordinator = useFileSaveCoordinator(props); - const onChange = (contents: string) => coordinator.change(contents); - useEffect(() => { - latestChange = onChange; - }); - return null; -} - -async function render(props = defaultProps) { - root ??= createRoot(document.createElement("div")); - await act(async () => - root!.render( - - - , - ), - ); -} -const mount = render; - -async function unmountSurface() { - await act(async () => root!.unmount()); - root = null; -} - -const changeHandler = () => latestChange; - -beforeEach(() => { - root = null; - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); confirmFile.mockReset(); readScope.mockReset().mockReturnValue(true); getUnsavedFile.mockReset().mockReturnValue(null); onPendingChange.mockReset(); -}); +} -afterEach(async () => { - if (root) await unmountSurface(); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); +async function mount(props = defaultProps) { + let latestChange: (contents: string) => void = () => {}; + function FileSurface(props: Parameters[0]) { + const coordinator = useFileSaveCoordinator(props); + useEffect(() => { + latestChange = (contents) => coordinator.change(contents); + }); + return null; + } + const ui = (props: Parameters[0]) => ( + + + + ); + const screen = await render(ui(props), { router: false }); + return { + changeHandler: () => latestChange, + rerender: (props: Parameters[0]) => screen.rerender(ui(props)), + unmount: screen.unmount, + [Symbol.asyncDispose]: () => screen[Symbol.asyncDispose](), + }; +} describe("file-save React lifecycle", () => { it("persists editor model changes after StrictMode setup replay", async () => { - await mount(); - changeHandler()("AUDIT7907NATIVE\n"); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + screen.changeHandler()("AUDIT7907NATIVE\n"); expect(onPendingChange).toHaveBeenCalledWith("file.txt", true); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ @@ -104,35 +87,13 @@ describe("file-save React lifecycle", () => { expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); }); - it("persists rendered Markdown task changes after StrictMode setup replay", async () => { - await mount({ ...defaultProps, relativePath: "README.md" }); - const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true); - changeHandler()(nextContents); - await vi.advanceTimersByTimeAsync(500); - expect(writeFile).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { cwd: "/workspace", relativePath: "README.md", contents: "- [x] task\n" }, - }); - }); - - it("keeps the debounce across rerenders of the same file", async () => { - await mount(); - changeHandler()("first"); - await vi.advanceTimersByTimeAsync(300); - await render(defaultProps); - changeHandler()("latest"); - await vi.advanceTimersByTimeAsync(499); - expect(writeFile).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - expect(writeFile).toHaveBeenCalledTimes(1); - expect(writeFile.mock.calls[0]![0].input.contents).toBe("latest"); - }); - it("flushes on unmount and ignores a retired editor callback", async () => { - await mount(); - const retiredChange = changeHandler(); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + const retiredChange = screen.changeHandler(); retiredChange("pending edit"); - await unmountSurface(); + await screen.unmount(); retiredChange("stale editor contents"); await vi.runAllTimersAsync(); expect(writeFile).toHaveBeenCalledTimes(1); @@ -142,11 +103,13 @@ describe("file-save React lifecycle", () => { it.each([false, true])( "keeps edits pending after permission is revoked before a React update (unmount: %s)", async (unmount) => { - await mount(); - changeHandler()("pending edit"); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + screen.changeHandler()("pending edit"); readScope.mockReturnValue(false); if (unmount) { - await unmountSurface(); + await screen.unmount(); } await vi.runAllTimersAsync(); expect(writeFile).not.toHaveBeenCalled(); @@ -156,14 +119,16 @@ describe("file-save React lifecycle", () => { ); it("resumes an unsaved draft when write permission returns after effect replay", async () => { + setup(); + using _timers = fakeTimers(); readScope.mockReturnValue(false); getUnsavedFile.mockReturnValue({ contents: "pending draft" }); - await mount(); + await using screen = await mount(); await vi.runAllTimersAsync(); expect(writeFile).not.toHaveBeenCalled(); readScope.mockReturnValue(true); - await render(defaultProps); + await screen.rerender(defaultProps); await vi.advanceTimersByTimeAsync(500); expect(writeFile).toHaveBeenCalledExactlyOnceWith({ environmentId, @@ -172,29 +137,20 @@ describe("file-save React lifecycle", () => { expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); }); - it("recovers an existing draft once after StrictMode setup replay", async () => { - getUnsavedFile.mockReturnValue({ contents: "reopened draft" }); - await mount(); - await vi.runAllTimersAsync(); - expect(writeFile).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { cwd: "/workspace", relativePath: "file.txt", contents: "reopened draft" }, - }); - expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false); - }); - it.each([ { relativePath: "other.txt" }, { cwd: "/other-workspace" }, { environmentId: EnvironmentId.make("other-environment") }, ])("retires callbacks when the file identity changes: %j", async (change) => { - await mount(); - const retiredChange = changeHandler(); + setup(); + using _timers = fakeTimers(); + await using screen = await mount(); + const retiredChange = screen.changeHandler(); retiredChange("old file edit"); const nextProps = { ...defaultProps, ...change }; - await render(nextProps); + await screen.rerender(nextProps); retiredChange("stale editor contents"); - changeHandler()("new file edit"); + screen.changeHandler()("new file edit"); await vi.runAllTimersAsync(); expect(writeFile.mock.calls.map(([request]) => request)).toEqual([ { @@ -211,16 +167,4 @@ describe("file-save React lifecycle", () => { }, ]); }); - - it("does not reactivate a retired callback when the same file mounts again", async () => { - await mount(); - const retiredChange = changeHandler(); - await unmountSurface(); - await mount(); - retiredChange("stale contents"); - changeHandler()("current contents"); - await vi.runAllTimersAsync(); - expect(writeFile).toHaveBeenCalledTimes(1); - expect(writeFile.mock.calls[0]![0].input.contents).toBe("current contents"); - }); }); diff --git a/apps/web/src/components/media/AttachmentImage.browser.test.tsx b/apps/web/src/components/media/AttachmentImage.browser.test.tsx deleted file mode 100644 index a99c64dff9ab..000000000000 --- a/apps/web/src/components/media/AttachmentImage.browser.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { AttachmentImage } from "./AttachmentImage"; -import { prepareImageForAttachment } from "../../lib/imageCompression"; - -vi.mock("../../lib/imageCompression", async (importOriginal) => ({ - ...(await importOriginal()), - prepareImageForAttachment: vi.fn(), -})); - -let root: Root | undefined; -let container: HTMLDivElement | undefined; -const fetchImage = vi.fn(); -const revokeUrl = vi.fn(); -const jpeg = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" }); - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("fetch", fetchImage); - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); - fetchImage.mockResolvedValue(new Response(new Blob(["heic"]))); - vi.mocked(prepareImageForAttachment).mockResolvedValue({ - ok: true, - file: jpeg, - recompressed: true, - }); -}); - -afterEach(async () => { - await act(() => root?.unmount()); - container?.remove(); - root = undefined; - container = undefined; - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - vi.clearAllMocks(); -}); - -async function render(element: ReactNode) { - if (!container) { - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - } - await act(() => root!.render(element)); -} - -function image() { - const img = container!.querySelector("img"); - expect(img).not.toBeNull(); - return img!; -} - -// Stands in for the browser failing to decode the current source. -async function failDecode() { - const img = image(); - await act(() => { - img.dispatchEvent(new Event("error")); - }); - return img; -} - -function expectDecodeErrorFor(onError: ReturnType, img: HTMLImageElement) { - expect(onError).toHaveBeenCalledWith(expect.objectContaining({ type: "error", target: img })); -} - -it.each([ - { name: "photo.HEIC", mimeType: undefined }, - { name: "photo", mimeType: "image/heif" }, -])( - "converts $name after native decoding fails and releases its preview", - async ({ name, mimeType }) => { - await render( - , - ); - expect(fetchImage).not.toHaveBeenCalled(); - await failDecode(); - expect(image().getAttribute("src")).toBe("blob:jpeg"); - expect(prepareImageForAttachment).toHaveBeenCalledWith( - expect.objectContaining({ name, type: "image/heic" }), - 50 * 1024 * 1024, - ); - await act(() => root!.unmount()); - root = undefined; - expect(revokeUrl).toHaveBeenCalledWith("blob:jpeg"); - }, -); - -it("leaves PNG errors to the caller without fetching or converting", async () => { - const onError = vi.fn(); - await render(); - expectDecodeErrorFor(onError, await failDecode()); - expect(fetchImage).not.toHaveBeenCalled(); -}); - -it("reports failed HEIC conversion through the existing error handler", async () => { - const onError = vi.fn(); - vi.mocked(prepareImageForAttachment).mockResolvedValue({ ok: false, reason: "unreadable" }); - await render(); - expectDecodeErrorFor(onError, await failDecode()); - expect(URL.createObjectURL).not.toHaveBeenCalled(); -}); - -it("discards an old decode when navigating to a different source", async () => { - let finishDecode!: (result: Awaited>) => void; - vi.mocked(prepareImageForAttachment).mockReturnValue( - new Promise((resolve) => { - finishDecode = resolve; - }), - ); - await render(); - await failDecode(); - await render(); - await act(() => finishDecode({ ok: true, file: jpeg, recompressed: true })); - expect(image().getAttribute("src")).toBe("blob:second"); - expect(URL.createObjectURL).not.toHaveBeenCalled(); - expect(fetchImage.mock.calls[0]?.[1].signal.aborted).toBe(true); -}); diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx index 1b12a92e74c9..aeb93be9212d 100644 --- a/apps/web/src/components/media/MediaActions.test.tsx +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -5,7 +5,6 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { createElement, isValidElement, type ReactNode } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -79,7 +78,6 @@ vi.mock("../ui/toast", () => ({ import { MediaActions, useMediaActions, type MediaActionSource } from "./MediaActions"; const environmentId = EnvironmentId.make("media-environment"); -const otherEnvironmentId = EnvironmentId.make("other-environment"); const threadId = ThreadId.make("media-thread"); const granted: Pick = { authenticated: true, @@ -144,109 +142,6 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); -it.each([ - [ - "image", - "C:/repo/assets/shot.png", - "C:/repo", - String.raw`C:\repo\assets\shot.png`, - String.raw`assets\shot.png`, - ], - [ - "video", - String.raw`\\server\share\repo/assets/clip.mp4`, - String.raw`\\server\share\repo`, - String.raw`\\server\share\repo\assets\clip.mp4`, - String.raw`assets\clip.mp4`, - ], -] as const)( - "copies full and relative paths for %s %s", - async (kind, path, root, full, relative) => { - const source: MediaActionSource = { - ...hostSource(), - kind, - reference: mediaFileReference(path, root), - }; - for (const [action, expected] of [ - ["copy-full-path", full], - ["copy-relative-path", relative], - ] as const) { - const completed = deferred(); - state.menuFinished = () => completed.resolve(); - state.showMenu.mockResolvedValue(action); - openMenu(source); - await completed.promise; - expect(state.copyText).toHaveBeenLastCalledWith(expected, "file path"); - } - expect(state.mint).not.toHaveBeenCalled(); - expect(state.download).not.toHaveBeenCalled(); - }, -); - -it("preserves media URLs when copying", async () => { - const url = "https://cdn.test/assets/clip.mp4?path=C:/repo/media"; - const completed = deferred(); - state.menuFinished = () => completed.resolve(); - state.showMenu.mockResolvedValue("copy-url"); - openMenu({ ...hostSource(), kind: "video", src: url, reference: { kind: "url", url } }); - await completed.promise; - expect(state.copyText).toHaveBeenCalledWith(url, "URL"); - expect(state.mint).not.toHaveBeenCalled(); -}); - -it.each(["workspace-file", "media-file"] as const)( - "waits for the %s grant before enabling host menu actions", - (_tag) => { - for (const [session, disabled] of [ - [null, true], - [granted, false], - [denied, true], - ] as const) { - if (session === null) state.sessions.delete(environmentId); - else state.sessions.set(environmentId, session); - openMenu(hostSource(_tag)); - - const items = state.showMenu.mock.lastCall![0]; - expect(items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "save", disabled }), - expect.objectContaining({ id: "copy-image", disabled }), - expect.objectContaining({ id: "open-file", disabled }), - ]), - ); - expect(items).toContainEqual({ id: "copy-full-path", label: "Copy full path" }); - } - expect(state.mint).not.toHaveBeenCalled(); - expect(state.download).not.toHaveBeenCalled(); - }, -); - -it("keeps nonhost menu actions available with pending or denied host grants", () => { - const sources: MediaActionSource[] = [ - { kind: "image", name: "image.png", src: "https://cdn.test/image.png" }, - { kind: "image", name: "image.png", src: "blob:local-image" }, - { - kind: "image", - name: "image.png", - src: null, - asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, - }, - ]; - for (const session of [null, denied]) { - if (session === null) state.sessions.delete(environmentId); - else state.sessions.set(environmentId, session); - for (const source of sources) { - openMenu(source); - expect(state.showMenu.mock.lastCall![0]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "save", disabled: false }), - expect.objectContaining({ id: "copy-image", disabled: false }), - ]), - ); - } - } -}); - it.each(["workspace-file", "media-file"] as const)( "disables denied %s byte actions and prevents imperative requests", async (_tag) => { @@ -289,72 +184,3 @@ it.each(["save", "copy-image", "open-file"])( expect(state.openFile).not.toHaveBeenCalled(); }, ); - -it("reenables the menu and a retained action when file access is gained", async () => { - const actions = useMediaActions(hostSource()); - state.sessions.set(environmentId, granted); - openMenu(hostSource()); - - await actions.save(); - - expect(state.download).toHaveBeenCalledOnce(); - expect(state.showMenu).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: "save", disabled: false })]), - expect.anything(), - ); -}); - -it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => { - state.sessions.set(environmentId, allowed ? granted : denied); - state.sessions.set(otherEnvironmentId, allowed ? denied : granted); - - await useMediaActions(hostSource()) - .save() - .catch(() => {}); - - expect(state.mint).toHaveBeenCalledTimes(allowed ? 1 : 0); - expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0); -}); - -it("stops before downloading if access is revoked while minting the URL", async () => { - state.sessions.set(environmentId, granted); - state.mint.mockImplementation(async () => { - state.sessions.set(environmentId, denied); - return AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }); - }); - - await expect(useMediaActions(hostSource()).save()).rejects.toThrow("cannot read host files"); - - expect(state.download).not.toHaveBeenCalled(); -}); - -it("lets the server authorize an explicit action before the grant resolves", async () => { - state.sessions.delete(environmentId); - - await useMediaActions(hostSource()).save(); - - expect(state.mint).toHaveBeenCalledOnce(); - expect(state.download).toHaveBeenCalledOnce(); -}); - -it("saves uploaded attachments without filesystem access", async () => { - await useMediaActions({ - kind: "image", - name: "image.png", - src: null, - asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } }, - }).save(); - - expect(state.mint).toHaveBeenCalledOnce(); - expect(state.download).toHaveBeenCalledOnce(); -}); - -it.each(["https://cdn.test/image.png", "blob:local-image"])( - "saves direct media without a host grant: %s", - async (src) => { - await useMediaActions({ kind: "image", name: "image.png", src }).save(); - - expect(state.mint).not.toHaveBeenCalled(); - expect(state.download).toHaveBeenCalledWith(src, "image.png"); - }, -); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 76f5f65ef646..069ac27df3ca 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -9,11 +9,10 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import "~/index.css"; +import { act, render } from "../../../test/render"; type SessionResult = AsyncResult.AsyncResult; type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string }; @@ -136,7 +135,6 @@ import { WelcomeWizard } from "./WelcomeWizard"; const primaryId = EnvironmentId.make("primary"); const remoteId = EnvironmentId.make("remote"); const permissionMessage = "This connection cannot import projects or thread history."; -const roots: Root[] = []; function session(scopes: ReadonlyArray): AuthSessionState { return { @@ -166,13 +164,16 @@ function setGrant(environmentId: EnvironmentId, allowed: boolean) { ); } +// Default selection only picks recently active projects, so pin "now". +const NOW = Date.parse("2026-01-01T00:00:00.000Z"); + function candidate(path: string): AgentSessionProjectCandidate { return { path, title: path.split("/").at(-1)!, sources: ["codex"], threadCount: 10, - lastActiveAt: new Date(Date.now() - 60_000).toISOString(), + lastActiveAt: new Date(NOW - 60_000).toISOString(), alreadyImported: false, }; } @@ -211,18 +212,11 @@ async function click(label: string) { await act(async () => target.click()); } -function projectCheckboxes() { - return [...document.querySelectorAll('[role="checkbox"]')]; -} - async function mountImport(remote = false) { state.selectedEnvironment = remote ? "remote" : "primary"; - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render( + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( , @@ -231,9 +225,15 @@ async function mountImport(remote = false) { await click("Continue"); await click("Continue"); expect(pageText()).toContain("Import your projects"); + stack.move(); + return screen; } -beforeEach(() => { +// The mocked modules read `state`, so every test starts by replacing all of it. +// Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test. +function setup() { + const now = vi.spyOn(Date, "now").mockReturnValue(NOW); + state.sessions.clear(); for (const id of [primaryId, remoteId]) { state.sessions.set( id, @@ -258,74 +258,17 @@ beforeEach(() => { state.onDone.mockReset(); state.scan.mockReset(); state.refreshScan.mockReset(); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - state.sessions.clear(); -}); - -it("keeps scanning, choosing and skipping available to a paired read-only environment", async () => { - setGrant(remoteId, false); - await mountImport(true); - expect(state.scan).toHaveBeenCalledWith(expect.objectContaining({ environmentId: remoteId })); - expect(pageText()).toContain("/projects/first"); - expect(button("Import 2 projects").disabled).toBe(true); - expect(pageText()).toContain(permissionMessage); - await click("Do not import projects"); - expect(state.createProject).not.toHaveBeenCalled(); - expect(state.importThreads).not.toHaveBeenCalled(); - expect(state.completeOnboarding).toHaveBeenCalledOnce(); - expect(state.onDone).toHaveBeenCalledOnce(); -}); - -it("waits for the selected grant and enables import when it arrives", async () => { - appAtomRegistry.set(sessionAtom(primaryId), AsyncResult.initial()); - await mountImport(); - expect(button("Import 2 projects").disabled).toBe(true); - await act(async () => setGrant(primaryId, true)); - await click("Import 2 projects"); - expect(state.createProject).toHaveBeenCalledTimes(2); - expect(state.importThreads).toHaveBeenCalledTimes(2); - expect(state.onDone).toHaveBeenCalledWith(expect.objectContaining({ environmentId: primaryId })); -}); - -it("preserves selections and denies a click that lands as the paired environment's grant is revoked", async () => { - await mountImport(true); - await act(async () => projectCheckboxes()[1]!.click()); - expect(projectCheckboxes()[1]!.getAttribute("aria-checked")).toBe("false"); - const importButton = button("Import 1 project"); - // The click reaches the still-enabled button before React re-renders the revocation. - await act(async () => { - setGrant(remoteId, false); - importButton.click(); - }); - expect(button("Import 1 project").disabled).toBe(true); - expect(projectCheckboxes()[1]!.getAttribute("aria-checked")).toBe("false"); - expect(pageText()).toContain(permissionMessage); - expect(state.createProject).not.toHaveBeenCalled(); - expect(state.importThreads).not.toHaveBeenCalled(); - expect(state.onDone).not.toHaveBeenCalled(); - - await act(async () => setGrant(remoteId, true)); - expect(pageText()).not.toContain(permissionMessage); - await click("Import 1 project"); - expect(state.createProject).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ environmentId: remoteId }), - ); - expect(state.importThreads).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ environmentId: remoteId }), - ); -}); + return now; +} it("rechecks after project creation and can retry its history without recreating the project", async () => { + using _now = setup(); const creation = deferred(); state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => { await creation.promise; return recordProject(input); }); - await mountImport(); + await using _screen = await mountImport(); await click("Import 2 projects"); expect(state.createProject).toHaveBeenCalledOnce(); await act(async () => { @@ -345,49 +288,14 @@ it("rechecks after project creation and can retry its history without recreating expect(state.onDone).toHaveBeenCalledOnce(); }); -it.each([false, true])( - "rechecks after history import before the next project write (existing: %s)", - async (existing) => { - if (existing) { - const projectId = ProjectId.make("second-existing"); - state.candidates[1] = { ...state.candidates[1]!, projectId }; - appAtomRegistry.set(projectsAtom(), [ - { id: projectId, environmentId: primaryId, workspaceRoot: "/projects/second" }, - ]); - } - const imported = deferred<{ importedCount: number; skippedCount: number }>(); - state.importThreads.mockImplementationOnce(async () => - AsyncResult.success(await imported.promise), - ); - await mountImport(); - await click("Import 2 projects"); - expect(state.createProject).toHaveBeenCalledOnce(); - expect(state.importThreads).toHaveBeenCalledOnce(); - await act(async () => { - setGrant(primaryId, false); - imported.resolve({ importedCount: 1, skippedCount: 0 }); - await imported.promise; - }); - expect(state.createProject).toHaveBeenCalledOnce(); - expect(state.importThreads).toHaveBeenCalledOnce(); - expect(state.onDone).not.toHaveBeenCalled(); - expect(pageText()).toContain(permissionMessage); - - await act(async () => setGrant(primaryId, true)); - await click("Import 2 projects"); - expect(state.createProject).toHaveBeenCalledTimes(existing ? 1 : 2); - expect(state.importThreads).toHaveBeenCalledTimes(2); - expect(state.onDone).toHaveBeenCalledOnce(); - }, -); - it("completes accepted imports when permission is revoked after the final server write", async () => { + using _now = setup(); state.candidates = state.candidates.slice(0, 1); const imported = deferred<{ importedCount: number; skippedCount: number }>(); state.importThreads.mockImplementationOnce(async () => AsyncResult.success(await imported.promise), ); - await mountImport(); + await using _screen = await mountImport(); await click("Import 1 project"); await act(async () => { setGrant(primaryId, false); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx deleted file mode 100644 index ebc2f5044800..000000000000 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.browser.test.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import { RegistryContext } from "@effect/atom-react"; -import { - BearerConnectionTarget, - PrimaryConnectionTarget, -} from "@t3tools/client-runtime/connection"; -import { - AuthTerminalOperateScope, - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type AuthEnvironmentScope, - type AuthSessionState, - type ResolvedKeybindingsConfig, - type ServerProvider, - type ServerSettings, - type TerminalCloseInput, - type TerminalOpenInput, - type TerminalWriteInput, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import * as Cause from "effect/Cause"; -import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import "~/index.css"; - -type SessionResult = AsyncResult.AsyncResult; -type CommandResult = AsyncResult.AsyncResult; -type Request = { environmentId: EnvironmentId; input: Input }; -type SetupConfig = { - cwd: string; - settings: ServerSettings; - environment: { platform: { os: "darwin" } }; - keybindings: ResolvedKeybindingsConfig; -}; -const state = vi.hoisted(() => ({ - sessions: new Map>(), - providers: new Map>>(), - configs: new Map>(), - open: vi.fn<(request: Request) => Promise>(), - write: vi.fn<(request: Request) => Promise>(), - close: vi.fn<(request: Request) => Promise>(), - refresh: vi.fn(), - pair: vi.fn(), - createProject: vi.fn(), - importThreads: vi.fn(), - complete: vi.fn<() => Promise>(), - done: vi.fn(), -})); - -// Session grants come from per-test atoms so a test can revoke or regrant mid-flow; the -// real scope checks in ~/state/session read them through the app registry, which the -// wizard is also rendered with. -vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { - const actual = await original(); - return { - ...actual, - createEnvironmentSessionAtoms: ((runtime: never) => ({ - ...actual.createEnvironmentSessionAtoms(runtime), - sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, - })) as never, - }; -}); -vi.mock(import("../../state/environments"), async (original) => ({ - ...(await original()), - useEnvironments: (() => ({ environments: [remoteEnvironment] })) as never, - usePrimaryEnvironment: (() => primaryEnvironment) as never, -})); -vi.mock(import("../../state/server"), async (original) => { - const actual = await original(); - return { - ...actual, - serverEnvironment: { - ...actual.serverEnvironment, - providersValueAtom: ((id: EnvironmentId) => state.providers.get(id)!) as never, - configValueAtom: ((id: EnvironmentId) => state.configs.get(id)!) as never, - refreshProviders: "refresh" as never, - }, - }; -}); -vi.mock(import("../../state/terminal"), async (original) => ({ - ...(await original()), - terminalEnvironment: { open: "open", write: "write", close: "close" } as never, -})); -vi.mock(import("../../state/use-atom-command"), async (original) => ({ - ...(await original()), - useAtomCommand: (( - command: "open" | "write" | "close" | "refresh" | "pair" | "createProject" | "importThreads", - ) => state[command]) as never, -})); -vi.mock(import("../../connection/onboarding"), async (original) => ({ - ...(await original()), - connectPairing: "pair" as never, -})); -vi.mock(import("../../state/agentSessions"), async (original) => ({ - ...(await original()), - agentSessionImport: "importThreads" as never, -})); -vi.mock(import("../../state/projects"), async (original) => ({ - ...(await original()), - projectEnvironment: { create: "createProject" } as never, -})); -vi.mock(import("../../state/entities"), async (original) => ({ - ...(await original()), - readProjects: () => [], - useProjects: () => [], -})); -vi.mock(import("../../onboarding/useProjectScans"), async (original) => ({ - ...(await original()), - useProjectScans: () => [], -})); -vi.mock(import("../../onboarding/firstRun"), async (original) => ({ - ...(await original()), - completeOnboarding: state.complete, -})); -vi.mock(import("../../cloud/publicConfig"), async (original) => ({ - ...(await original()), - hasCloudPublicConfig: () => false, -})); -vi.mock(import("../settings/ChatGptWelcomeCoordinator"), async (original) => ({ - ...(await original()), - ChatGptWelcomeCoordinator: () => <>, -})); -// The real viewport mounts a Ghostty terminal against the PTY; these tests only need to -// see whether the wizard shows it. -vi.mock(import("../ThreadTerminalDrawer"), async (original) => ({ - ...(await original()), - TerminalViewport: () =>
, -})); - -import { appAtomRegistry } from "../../rpc/atomRegistry"; -import { WelcomeWizard } from "./WelcomeWizard"; - -const primaryId = EnvironmentId.make("primary"); -const remoteId = EnvironmentId.make("paired-remote"); -const primaryEnvironment = { - environmentId: primaryId, - label: "This computer", - connection: { phase: "connected" }, - entry: { - enabled: true, - target: new PrimaryConnectionTarget({ - environmentId: primaryId, - label: "This computer", - httpBaseUrl: "http://127.0.0.1:3773", - wsBaseUrl: "ws://127.0.0.1:3773", - }), - }, -} as const; -const remoteEnvironment = { - environmentId: remoteId, - label: "Paired computer", - connection: { phase: "connected" }, - entry: { - enabled: true, - target: new BearerConnectionTarget({ - environmentId: remoteId, - label: "Paired computer", - connectionId: "paired-remote", - }), - }, -} as const; -const missingClaude: ServerProvider = { - instanceId: ProviderInstanceId.make("claude-work"), - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - installed: false, - version: null, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-09-05T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], -}; -const signedOutCodex: ServerProvider = { - ...missingClaude, - instanceId: ProviderInstanceId.make("codex-work"), - driver: ProviderDriverKind.make("codex"), - installed: true, - auth: { status: "unauthenticated" }, -}; -const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({ - authenticated: true, - scopes, - auth: { - policy: "remote-reachable", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["bearer-access-token"], - sessionCookieName: "t3_session", - }, -}); -function setAccess(environmentId: EnvironmentId, allowed: boolean) { - appAtomRegistry.set( - state.sessions.get(environmentId)!, - AsyncResult.success(session(allowed ? [AuthTerminalOperateScope] : [])), - ); -} -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((complete) => { - resolve = complete; - }); - return { promise, resolve }; -} -const roots: Root[] = []; -function pageText() { - return document.body.textContent ?? ""; -} -function button(label: string) { - const found = [...document.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label || element.ariaLabel === label, - ); - if (!found) throw new Error(`Missing button: ${label}`); - return found; -} -async function click(label: string) { - const node = button(label); - expect(node.disabled).toBe(false); - await act(async () => node.click()); -} -function hasViewport() { - return document.querySelector("[data-terminal-viewport]") !== null; -} -async function enterRemoteAgents() { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.push(root); - await act(async () => - root.render( - - - , - ), - ); - await click("Continue"); - expect(pageText()).toContain("Paired computer"); -} - -beforeEach(() => { - for (const id of [primaryId, remoteId]) { - state.sessions.set( - id, - Atom.make(AsyncResult.success(session([AuthTerminalOperateScope]))).pipe( - Atom.keepAlive, - ), - ); - state.providers.set( - id, - Atom.make>([missingClaude, signedOutCodex]).pipe( - Atom.keepAlive, - ), - ); - state.configs.set( - id, - Atom.make({ - cwd: `/fixtures/${id}`, - settings: { - ...DEFAULT_SERVER_SETTINGS, - providerInstances: { - [signedOutCodex.instanceId]: { - driver: signedOutCodex.driver, - config: { binaryPath: "/opt/codex-work", setupMode: "existing" }, - }, - }, - }, - environment: { platform: { os: "darwin" } }, - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - }), - ); - } - state.open.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.write.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - state.pair.mockReset().mockResolvedValue(AsyncResult.success(remoteId)); - state.complete.mockReset().mockResolvedValue(undefined); - state.done.mockReset(); -}); - -afterEach(async () => { - for (const root of roots.splice(0)) await act(async () => root.unmount()); - document.body.replaceChildren(); - state.sessions.clear(); - state.providers.clear(); - state.configs.clear(); -}); - -describe("welcome agent terminal setup", () => { - it("disables both setup actions for the paired read-only connection and preserves local completion", async () => { - setAccess(remoteId, false); - await enterRemoteAgents(); - expect(button("Install").disabled).toBe(true); - expect(button("Sign in").disabled).toBe(true); - expect(pageText()).toContain("This connection cannot control terminals."); - await act(async () => { - button("Install").click(); - button("Sign in").click(); - }); - expect(state.open).not.toHaveBeenCalled(); - expect(state.write).not.toHaveBeenCalled(); - expect(state.close).not.toHaveBeenCalled(); - await click("Continue"); - await click("Do not import projects"); - expect(state.complete).toHaveBeenCalledOnce(); - expect(state.done).toHaveBeenCalledExactlyOnceWith(undefined); - }); - - it("rejects a retained install action after revocation and allows a terminal-only regrant", async () => { - setAccess(primaryId, false); - await enterRemoteAgents(); - const install = button("Install"); - // The click reaches the still-enabled button before React re-renders the revocation. - await act(async () => { - setAccess(remoteId, false); - install.click(); - }); - expect(state.open).not.toHaveBeenCalled(); - expect(button("Install").disabled).toBe(true); - await act(async () => setAccess(remoteId, true)); - expect(button("Install").disabled).toBe(false); - await click("Install"); - expect(state.open).toHaveBeenCalledExactlyOnceWith({ - environmentId: remoteId, - input: { - threadId: "onboarding-agent-setup", - terminalId: expect.any(String), - cwd: "/fixtures/paired-remote", - providerInstanceId: missingClaude.instanceId, - }, - }); - expect(state.write).toHaveBeenCalledExactlyOnceWith({ - environmentId: remoteId, - input: { - threadId: "onboarding-agent-setup", - terminalId: state.open.mock.calls[0]![0].input.terminalId, - data: "curl -fsSL https://claude.ai/install.sh | bash", - }, - }); - expect(hasViewport()).toBe(true); - await click("Close"); - expect(state.close).toHaveBeenCalledExactlyOnceWith({ - environmentId: remoteId, - input: { - threadId: "onboarding-agent-setup", - terminalId: state.open.mock.calls[0]![0].input.terminalId, - deleteHistory: true, - }, - }); - }); - - it("pretypes the selected provider's sign-in command without executing it", async () => { - setAccess(primaryId, false); - await enterRemoteAgents(); - await click("Sign in"); - expect(state.open).toHaveBeenCalledExactlyOnceWith({ - environmentId: remoteId, - input: { - threadId: "onboarding-agent-setup", - terminalId: expect.any(String), - cwd: "/fixtures/paired-remote", - providerInstanceId: signedOutCodex.instanceId, - }, - }); - expect(state.write).toHaveBeenCalledExactlyOnceWith({ - environmentId: remoteId, - input: { - threadId: "onboarding-agent-setup", - terminalId: state.open.mock.calls[0]![0].input.terminalId, - data: "/opt/codex-work login", - }, - }); - expect(pageText()).toContain("Review the command, then press Enter to run it."); - }); - - it("checks access when a queued retry starts and recovers after regrant", async () => { - state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Could not open")))); - await enterRemoteAgents(); - await click("Install"); - const closing = deferred(); - state.close.mockReturnValueOnce(closing.promise); - await click("Retry"); - expect(state.close).toHaveBeenCalledOnce(); - expect(state.open).toHaveBeenCalledOnce(); - await act(async () => { - setAccess(remoteId, false); - closing.resolve(AsyncResult.success(undefined)); - }); - expect(state.open).toHaveBeenCalledOnce(); - expect(button("Retry").disabled).toBe(true); - expect(state.write).not.toHaveBeenCalled(); - await act(async () => setAccess(remoteId, true)); - await click("Retry"); - expect(state.open).toHaveBeenCalledTimes(2); - expect(state.write).toHaveBeenCalledOnce(); - expect(hasViewport()).toBe(true); - }); - - it("keeps an accepted terminal visible without pretyping when access is revoked during open", async () => { - const opening = deferred(); - state.open.mockReturnValueOnce(opening.promise); - await enterRemoteAgents(); - await click("Install"); - await act(async () => { - setAccess(remoteId, false); - opening.resolve(AsyncResult.success(undefined)); - }); - expect(state.write).not.toHaveBeenCalled(); - expect(state.close).not.toHaveBeenCalled(); - expect(hasViewport()).toBe(true); - expect(pageText()).toContain("This connection cannot control terminals."); - await act(async () => setAccess(remoteId, true)); - expect(state.open).toHaveBeenCalledOnce(); - expect(state.write).not.toHaveBeenCalled(); - expect(pageText()).toContain("Run curl -fsSL https://claude.ai/install.sh | bash in this"); - await act(async () => setAccess(remoteId, false)); - await click("Close"); - expect(hasViewport()).toBe(false); - expect(state.close).not.toHaveBeenCalled(); - await click("Continue"); - expect(pageText()).toContain("Import your projects"); - }); - - it("settles accepted pretyping locally after revocation without closing the PTY", async () => { - const writing = deferred(); - state.write.mockReturnValueOnce(writing.promise); - await enterRemoteAgents(); - await click("Sign in"); - await act(async () => { - setAccess(remoteId, false); - writing.resolve(AsyncResult.success(undefined)); - }); - expect(state.open).toHaveBeenCalledOnce(); - expect(state.write).toHaveBeenCalledOnce(); - expect(hasViewport()).toBe(true); - await act(async () => setAccess(remoteId, true)); - expect(pageText()).toContain("Review the command, then press Enter to run it."); - expect(state.open).toHaveBeenCalledOnce(); - expect(state.write).toHaveBeenCalledOnce(); - await act(async () => setAccess(remoteId, false)); - await click("Close"); - expect(state.close).not.toHaveBeenCalled(); - }); - - it("rechecks the original environment when cleanup runs after pending pretyping", async () => { - const writing = deferred(); - state.write.mockReturnValueOnce(writing.promise); - await enterRemoteAgents(); - await click("Install"); - await click("Connect, step 1"); - expect(state.close).not.toHaveBeenCalled(); - await act(async () => { - setAccess(remoteId, false); - writing.resolve(AsyncResult.success(undefined)); - }); - expect(state.close).not.toHaveBeenCalled(); - expect(pageText()).toContain("Connect your computers"); - }); -}); diff --git a/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx b/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx deleted file mode 100644 index a15e63337c59..000000000000 --- a/apps/web/src/components/permissions/usePermissionStatus.browser.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { usePermissionStatus } from "./usePermissionStatus"; - -type Status = { screen: boolean; accessibility: boolean }; -const check = vi.fn<() => Promise>(); - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -let root: Root | null; -let current: ReturnType>; -let visibility: DocumentVisibilityState; - -function Probe() { - const status = usePermissionStatus(check, { screen: false, accessibility: false }); - useEffect(() => { - current = status; - }); - return null; -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.useFakeTimers(); - visibility = "visible"; - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => visibility, - }); - check.mockReset().mockResolvedValue({ screen: false, accessibility: false }); - root = null; -}); -afterEach(async () => { - await unmount(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - Reflect.deleteProperty(document, "visibilityState"); -}); - -async function start() { - root = createRoot(document.createElement("div")); - await act(async () => root!.render()); -} -async function unmount() { - const mounted = root; - root = null; - if (mounted) await act(async () => mounted.unmount()); -} -const tick = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms)); - -it("unlocks Continue only for required grants and relocks on revocation", async () => { - await start(); - expect(current.isReady(["screen"])).toBe(false); - check.mockResolvedValue({ screen: true, accessibility: false }); - await tick(1500); - expect(current.isReady(["screen"])).toBe(true); - expect(current.isReady(["screen", "accessibility"])).toBe(false); - check.mockResolvedValue({ screen: true, accessibility: true }); - await tick(1500); - expect(current.isReady(["screen", "accessibility"])).toBe(true); - check.mockResolvedValue({ screen: false, accessibility: true }); - await act(async () => window.dispatchEvent(new Event("focus"))); - expect(current.isReady(["screen"])).toBe(false); -}); - -it("does not overlap checks and discards completion after closing", async () => { - const pending = deferred(); - check.mockReturnValue(pending.promise); - await start(); - await tick(4500); - window.dispatchEvent(new Event("focus")); - expect(check).toHaveBeenCalledTimes(1); - const last = current; - await unmount(); - await act(async () => pending.resolve({ screen: true, accessibility: true })); - expect(last.isReady(["screen"])).toBe(false); - expect(vi.getTimerCount()).toBe(0); -}); - -it("pauses in the background and blocks stale grants after a check failure", async () => { - check.mockResolvedValue({ screen: true, accessibility: true }); - await start(); - expect(current.isReady(["screen"])).toBe(true); - visibility = "hidden"; - await tick(3000); - expect(check).toHaveBeenCalledTimes(1); - check.mockRejectedValue(new Error("IPC unavailable")); - visibility = "visible"; - await act(async () => document.dispatchEvent(new Event("visibilitychange"))); - expect(current.isReady(["screen"])).toBe(false); - expect(current.error).not.toBeNull(); - check.mockResolvedValue({ screen: true, accessibility: true }); - await tick(1500); - expect(current.isReady(["screen"])).toBe(true); -}); diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index 3ac3856e79f3..8316d98e54d0 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { DesktopPreviewPointerEvent } from "@t3tools/contracts"; import { MousePointer2 } from "lucide-react"; import { useEffect, useState } from "react"; diff --git a/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx deleted file mode 100644 index a00a79bbf0df..000000000000 --- a/apps/web/src/components/preview/PreviewEmptyState.browser.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import type { PreviewableServer } from "./useDiscoveredLocalServers"; - -const mocks = vi.hoisted(() => ({ servers: [] as PreviewableServer[] })); - -vi.mock(import("./useDiscoveredLocalServers"), async (original) => ({ - ...(await original()), - useDiscoveredLocalServers: () => mocks.servers, -})); - -import { PreviewEmptyState } from "./PreviewEmptyState"; - -const environmentId = EnvironmentId.make("env-1"); -const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; -const onOpenUrl = vi.fn(); -const onRemoveRecent = vi.fn(); - -let root: Root; -let container: HTMLDivElement; - -function server(port: number): PreviewableServer { - return { - host: "localhost", - port, - url: `http://localhost:${port}`, - requestedUrl: `http://localhost:${port}`, - processName: "node", - pid: 1, - terminal: null, - source: "scanner", - } as PreviewableServer; -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - mocks.servers = []; - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function render( - recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>, -) { - await act(async () => - root.render( - , - ), - ); -} - -function button(name: string) { - const match = [...container.querySelectorAll("button")].find( - (element) => element.getAttribute("aria-label") === name || element.textContent?.includes(name), - ); - expect(match, `button ${name}`).toBeDefined(); - return match!; -} - -describe("PreviewEmptyState", () => { - it("lists a recent entry and a live server matching its host:port, and opens or forgets entries", async () => { - mocks.servers = [server(5173)]; - await render([ - { url: "https://myapp.test/admin#users", lastVisitedAt: Date.now(), title: "Admin" }, - { url: "http://localhost:5173/", lastVisitedAt: Date.now(), title: "Recent Local" }, - ]); - const text = container.textContent; - expect(text).toContain("Recently used"); - expect(text).toContain("Local servers"); - expect(text).toContain("Recent Local"); - expect(text).toContain("node"); - - await act(async () => button("Admin").click()); - expect(onOpenUrl).toHaveBeenCalledExactlyOnceWith("https://myapp.test/admin#users"); - - await act(async () => button("Remove myapp.test/admin#users from history").click()); - expect(onRemoveRecent).toHaveBeenCalledExactlyOnceWith("https://myapp.test/admin#users"); - }); - - it("shows only the recents group when no servers are found", async () => { - await render([{ url: "https://myapp.test/", lastVisitedAt: 0 }]); - expect(container.textContent).toContain("Recently used"); - expect(container.textContent).not.toContain("Local servers"); - }); - - it("keeps the original empty state when both groups are empty", async () => { - await render([]); - expect(container.textContent).toContain("No preview yet"); - }); - - it("shows an entry with an out-of-range lastVisitedAt instead of crashing", async () => { - await render([{ url: "https://myapp.test/", lastVisitedAt: 1e20 }]); - expect(container.textContent).toContain("myapp.test"); - button("Remove myapp.test from history"); - }); -}); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx deleted file mode 100644 index 010f4d2438ab..000000000000 --- a/apps/web/src/components/preview/PreviewFaviconIcon.browser.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { FaviconImage } from "./PreviewFaviconIcon"; - -const GIF = "data:image/gif;base64,R0lGODlhAQABAAAAACw="; -const captured = `${GIF}#captured`; -const remote = `${GIF}#remote`; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const image = () => container.querySelector("img"); -const failImage = () => act(async () => image()!.dispatchEvent(new Event("error"))); - -it("falls through failed favicon sources and retries when the source list changes", async () => { - await act(async () => - root.render(fallback} />), - ); - expect(image()?.getAttribute("src")).toBe(captured); - - await failImage(); - expect(image()?.getAttribute("src")).toBe(remote); - - await failImage(); - expect(image()).toBeNull(); - expect(container.textContent).toBe("fallback"); - - await act(async () => - root.render( - fallback} />, - ), - ); - expect(image()?.getAttribute("src")).toBe(captured); -}); diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 71b936b0ed70..5aa0ead9dc4f 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index 81501ae7d727..9de48226672f 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -1,5 +1,3 @@ -"use client"; - import { AuthPreviewOperateScope, type PreviewAnnotationPayload, diff --git a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx b/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx deleted file mode 100644 index bf6874368f1c..000000000000 --- a/apps/web/src/components/preview/PreviewPanelShell.a11y.browser.test.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { Dialog } from "@base-ui/react/dialog"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -import { PanelLayoutControls } from "../chat/PanelLayoutControls"; -import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function renderPanel( - open: boolean | undefined, - mode: PreviewPanelMode = "inline", - present = true, -) { - await act(async () => { - root.render( - - - - {present ? ( - - - - - ) : null} - -
- {}} - onToggleThreadPanel={() => {}} - onToggleRightPanel={() => {}} - /> -
-
-
-
, - ); - }); - await act(nextFrame); -} - -function nextFrame() { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); -} - -// Tabbing past the popup's last control wraps through Base UI's focus guard -// to the first tabbable descendant, which skips anything inert. -async function enterFocusScope() { - const toggle = document.querySelector('[aria-label="Toggle right panel"]')!; - await act(async () => toggle.focus()); - await act(() => userEvent.tab()); - await act(nextFrame); -} - -describe("right panel keyboard access", () => { - it("skips collapsed controls and restores access after reopening", async () => { - await renderPanel(false); - const outside = document.querySelector('[data-testid="outside"]')!; - expect(document.activeElement).toBe(outside); - await enterFocusScope(); - expect(document.activeElement).toBe(outside); - - await renderPanel(true); - await enterFocusScope(); - expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); - - await renderPanel(false); - await enterFocusScope(); - expect(document.activeElement).toBe(outside); - }); - - it("returns focus to the right-panel toggle when a focused panel closes", async () => { - await renderPanel(true); - const input = document.querySelector('[aria-label="Preview URL"]')!; - await act(async () => input.focus()); - await renderPanel(false); - expect(document.activeElement).toBe( - document.querySelector('[aria-label="Toggle right panel"]'), - ); - await renderPanel(true); - await enterFocusScope(); - expect(document.activeElement).toBe(input); - }); - - it("retains focus after the closing animation relocates the toggle", async () => { - await renderPanel(true); - await act(async () => { - document.querySelector('[aria-label="Preview URL"]')!.focus(); - }); - await renderPanel(false); - const closingToggle = document.querySelector('[aria-label="Toggle right panel"]'); - expect(document.activeElement).toBe(closingToggle); - await renderPanel(false, "inline", false); - const settledToggle = document.querySelector('[aria-label="Toggle right panel"]'); - expect(settledToggle).not.toBe(closingToggle); - expect(document.activeElement).toBe(settledToggle); - }); - - it("returns focus when the panel closes without an animation", async () => { - await renderPanel(true); - await act(async () => { - document.querySelector('[aria-label="Preview URL"]')!.focus(); - }); - await renderPanel(false, "inline", false); - expect(document.activeElement).toBe( - document.querySelector('[aria-label="Toggle right panel"]'), - ); - }); - - it("preserves focus moved outside while the closing animation settles", async () => { - await renderPanel(true); - await act(async () => { - document.querySelector('[aria-label="Preview URL"]')!.focus(); - }); - await renderPanel(false); - const outside = document.querySelector('[data-testid="outside"]')!; - await act(async () => outside.focus()); - await renderPanel(false, "inline", false); - expect(document.activeElement).toBe(outside); - }); - - it("preserves focus outside the panel when it closes", async () => { - await renderPanel(true); - const outside = document.querySelector('[data-testid="outside"]')!; - await act(async () => outside.focus()); - await renderPanel(false); - expect(document.activeElement).toBe(outside); - }); - - it.each(["sheet", "sidebar", "embedded"] as const)( - "keeps %s controls reachable when the inline open flag is false", - async (mode) => { - await renderPanel(false, mode); - expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); - }, - ); - - it("keeps non-collapsible inline controls reachable", async () => { - await renderPanel(undefined); - expect(document.activeElement).toBe(document.querySelector('[aria-label="Preview URL"]')); - }); -}); diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts deleted file mode 100644 index 2ad978998af7..000000000000 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { getPreviewPanelMaxWidth } from "./PreviewPanelShell"; - -describe("getPreviewPanelMaxWidth", () => { - it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => { - expect(getPreviewPanelMaxWidth(6_000)).toBe(4_200); - }); - - it("rounds fractional CSS pixels down", () => { - expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); - }); - - it("reserves the sibling column minimum when the flex row is known", () => { - expect(getPreviewPanelMaxWidth(1_512, 1_256)).toBe(896); - }); - - it("keeps the fraction cap when the row is wide enough for both columns", () => { - expect(getPreviewPanelMaxWidth(3_000, 2_900)).toBe(2_100); - }); - - it("rounds fractional row widths down", () => { - expect(getPreviewPanelMaxWidth(1_512, 1_256.6)).toBe(896); - }); - - it("never drops below the panel minimum when the row cannot fit both columns", () => { - expect(getPreviewPanelMaxWidth(1_000, 700)).toBe(360); - }); - - it("stays at the panel minimum even when the row is narrower than the reservation", () => { - expect(getPreviewPanelMaxWidth(1_512, 300)).toBe(360); - }); -}); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 1d9503e3a97c..f2658341446a 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -9,13 +9,10 @@ import { import { isElectron } from "~/env"; import { - getPreviewPanelMaxWidth, type PreviewPanelInlineSize, usePreviewPanelInlineSize, } from "~/hooks/usePreviewPanelInlineSize"; import { RESIZABLE_WIDTH_PROPERTY } from "~/hooks/useResizableWidth"; - -export { getPreviewPanelMaxWidth }; import { cn } from "~/lib/utils"; import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 3855354dc81e..e62c5defa797 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -7,12 +7,12 @@ import { FILL_PREVIEW_VIEWPORT, ThreadId, } from "@t3tools/contracts"; -import { act, Profiler, type ComponentProps, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render } from "../../../test/render"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; @@ -156,8 +156,6 @@ vi.mock(import("~/browser/previewRuntime"), async (original) => ({ vi.mock(import("~/browser/browserViewportActions"), { spy: true }); -import { commitBrowserViewportChange } from "~/browser/browserViewportActions"; -import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useBrowserPointerStore } from "~/browser/browserPointerStore"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -170,13 +168,6 @@ const TEST_THREAD_REF = { } as const; const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1"); -const roots: Root[] = []; -let root: Root; - -async function render(node: ReactNode) { - await act(async () => root.render(node)); -} - function view(onSendAnnotation?: ComponentProps["onSendAnnotation"]) { return ( { - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - mocks.readEnvironmentScope.mockReset().mockReturnValue(true); - mocks.setAnnotationSendEnabled.mockReset().mockResolvedValue(undefined); - mocks.pickElement.mockReset(); - mocks.pictureInPicture = false; - mocks.showEmptyState = false; - mocks.loading = false; - mocks.nativeServerTab = false; - mocks.serverEpoch = null; - mocks.servers = []; - mocks.recordingTabIds = new Set(); - mocks.recordingRuntimeTabId = null; - useComposerDraftStore.setState({ - addPreviewAnnotation: mocks.addPreviewAnnotation, - addImage: mocks.addImage, - }); - usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); - useBrowserPointerStore.setState({ byTabId: {} }); - const container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - roots.push(root); - }); - - afterEach(async () => { - for (const each of roots.splice(0)) await act(async () => each.unmount()); - document.body.replaceChildren(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("shows the cursor in a replacement browser while the old instance still records", async () => { - mocks.recordingTabIds.add(TEST_RUNTIME_TAB_ID); - mocks.recordingRuntimeTabId = TEST_RUNTIME_TAB_ID; - const replacementTabId = previewRuntimeTabId(TEST_THREAD_REF, "replacement-server", "tab-1"); - useBrowserPointerStore.setState({ - byTabId: Object.fromEntries( - [TEST_RUNTIME_TAB_ID, replacementTabId].map((tabId) => [ - tabId, - { tabId, phase: "move", x: 10, y: 10, sequence: 1, createdAt: "2026-07-27T00:00:00Z" }, - ]), - ), - }); - await render(view()); - expect(hasCursor()).toBe(false); - mocks.serverEpoch = "replacement-server"; - await render(view()); - expect(hasCursor()).toBe(true); - expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true); - }); - - it.each([false, true])( - "aligns the cursor and responsive viewport after host zoom changes (native server tab: %s)", - async (nativeServerTab) => { - mocks.nativeServerTab = nativeServerTab; - let windowZoom = 1; - vi.stubGlobal("desktopBridge", { - getWindowZoomFactor: () => windowZoom, - getLocalEnvironmentBootstraps: () => [], - }); - const commit = vi.mocked(commitBrowserViewportChange).mockResolvedValueOnce(undefined); - try { - await render(view()); - await act(() => { - const surfaces = useBrowserSurfaceStore.getState(); - const slot = document.querySelector("[data-browser-surface-slot]")!; - slot.style.width = "1020px"; - slot.style.height = "798px"; - surfaces.presentContent(TEST_RUNTIME_TAB_ID, { - x: 20, - y: 40, - width: 500, - height: 375, - scale: 0.5, - scrollLeft: 7, - scrollTop: 11, - }); - useBrowserPointerStore.setState({ - byTabId: { - [TEST_RUNTIME_TAB_ID]: { - tabId: TEST_RUNTIME_TAB_ID, - phase: "move", - x: 100, - y: 200, - sequence: 1, - createdAt: "2026-07-27T00:00:00Z", - }, - }, - }); - windowZoom = 1.25; - window.dispatchEvent(new Event("resize")); - }); - const cursor = document.querySelector("[data-agent-browser-cursor]"); - expect(cursor).not.toBeNull(); - const position = new DOMMatrixReadOnly(getComputedStyle(cursor!).transform); - expect(position.e).toBe(53); - expect(position.f).toBe(109); - - await userEvent.click(page.getByRole("button", { name: "Preview menu" })); - await userEvent.click(page.getByRole("menuitem", { name: "Show device toolbar" })); - expect(commit).toHaveBeenCalledExactlyOnceWith(TEST_RUNTIME_TAB_ID, { - _tag: "freeform", - width: 1250, - height: 938, - }); - } finally { - commit.mockReset(); - await act(() => useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} })); - } - }, - ); - - it("does not rerender while loading time passes", async () => { - vi.useFakeTimers(); - mocks.loading = true; - const onRender = vi.fn(); - await render( - - {view()} - , - ); - const initialRenderCount = onRender.mock.calls.length; - await act(() => vi.advanceTimersByTimeAsync(1_000)); - expect(onRender).toHaveBeenCalledTimes(initialRenderCount); - }); - - it.each([ - [ - "https://localhost:8000/dashboard?mode=test#top", - "https://localhost:8000/dashboard?mode=test#top", - ], - ["localhost:5173/app", "http://localhost:5173/app"], - ])( - "preserves a direct localhost URL %s in a WSL environment and records it", - async (submitted, expected) => { - await render(view()); - await submitAddress(submitted); - await vi.waitFor(() => - expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, expected), - ); - expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(TEST_THREAD_REF, expected); - expect(mocks.recordVisitForThread).toHaveBeenCalledWith(TEST_THREAD_REF, expected); - }, - ); - - it("maps an empty-state localhost server onto the WSL host", async () => { - mocks.showEmptyState = true; - mocks.servers = [ - { - host: "localhost", - port: 5173, - url: "http://localhost:5173/app?mode=test#top", - requestedUrl: "http://localhost:5173/app?mode=test#top", - processName: "vite", - pid: 1, - terminal: null, - source: "scanner", - } as unknown as PreviewableServer, - ]; - await render(view()); - await userEvent.click(page.getByRole("button", { name: /vite/ })); - - await vi.waitFor(() => - expect(mocks.navigate).toHaveBeenCalledWith( - TEST_RUNTIME_TAB_ID, - "http://172.25.85.75:5173/app?mode=test#top", - ), - ); - expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( - TEST_THREAD_REF, - "http://172.25.85.75:5173/app?mode=test#top", - ); - // History keys off the requested loopback URL, which stays stable across hosts. - await vi.waitFor(() => - expect(mocks.recordVisitForThread).toHaveBeenCalledWith( - TEST_THREAD_REF, - "http://localhost:5173/app?mode=test#top", - ), - ); - }); - - it("opens and closes a thread-scoped floating preview for the active tab", async () => { - await render(view()); - await userEvent.click(page.getByRole("button", { name: "Float preview over chat" })); - expect(usePreviewMiniPlayerStore.getState().byThreadKey).toMatchObject({ - "environment-1:thread-1": { source: { kind: "browser", tabId: "tab-1" } }, - }); - const closeButton = page.getByRole("button", { name: "Close floating preview" }); - await expect.element(closeButton).toHaveAttribute("aria-pressed", "true"); - await userEvent.click(closeButton); - expect(usePreviewMiniPlayerStore.getState().byThreadKey).toEqual({}); - }); - - it("keeps the native preview window as a secondary menu action", async () => { - await render(view()); - await userEvent.click(page.getByRole("button", { name: "Preview menu" })); - await userEvent.click(page.getByRole("menuitem", { name: "Open separate preview window" })); - await vi.waitFor(() => - expect(mocks.openPictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID), - ); - await vi.waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull()); - - mocks.pictureInPicture = true; - await render(view()); - await userEvent.click(page.getByRole("button", { name: "Preview menu" })); - await userEvent.click(page.getByRole("menuitem", { name: "Close separate preview window" })); - await vi.waitFor(() => - expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID), - ); - }); - - it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => { - const picked = annotation("annotation-1"); - const onSendAnnotation = vi.fn(); - mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); - await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); - - await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); - expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, picked); - }); - it("retains the annotation locally when its own environment loses access during capture", async () => { + setup(); const picked = annotation("annotation-revoked"); const onSendAnnotation = vi.fn(); let submitPick: (() => void) | undefined; @@ -449,7 +229,7 @@ describe("PreviewView", () => { submitPick = () => resolve({ annotation: picked, submission: "send" }); }), ); - await render(view(onSendAnnotation)); + await using screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(submitPick).toBeDefined()); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); @@ -457,7 +237,7 @@ describe("PreviewView", () => { mocks.readEnvironmentScope.mockImplementation( (environmentId?: unknown) => environmentId !== TEST_THREAD_REF.environmentId, ); - await render(view(onSendAnnotation)); + await screen.rerender(view(onSendAnnotation)); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, false); await act(async () => submitPick!()); await vi.waitFor(() => @@ -467,47 +247,14 @@ describe("PreviewView", () => { mocks.readEnvironmentScope.mockReturnValue(true); mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); + await screen.rerender(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true); }); - it("does not reopen a cancelled picker after the permission update finishes", async () => { - let finishUpdate: (() => void) | undefined; - mocks.setAnnotationSendEnabled.mockImplementationOnce( - () => - new Promise((resolve) => { - finishUpdate = resolve; - }), - ); - await render(view()); - await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); - await vi.waitFor(() => expect(finishUpdate).toBeDefined()); - expect(mocks.pickElement).not.toHaveBeenCalled(); - await userEvent.click(page.getByRole("button", { name: "Cancel annotation" })); - expect(mocks.cancelPickElement).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID); - await act(async () => finishUpdate!()); - expect(mocks.pickElement).not.toHaveBeenCalled(); - expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); - }); - - it("warns when main dropped the crop before handing over the pick", async () => { - const picked = annotation("annotation-3"); - const onSendAnnotation = vi.fn(); - mocks.pickElement.mockResolvedValue({ - annotation: picked, - submission: "send", - screenshotFailed: true, - }); - await render(view(onSendAnnotation)); - await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); - - await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(picked, null)); - expect(mocks.toast).toHaveBeenCalledOnce(); - }); - it("still sends annotation text when the picked element's crop is malformed", async () => { + setup(); const picked = annotation("annotation-2", { dataUrl: "data:image/png;base64,%%%", width: 10, @@ -516,7 +263,7 @@ describe("PreviewView", () => { }); const onSendAnnotation = vi.fn(); mocks.pickElement.mockResolvedValue({ annotation: picked, submission: "send" }); - await render(view(onSendAnnotation)); + await using _screen = await render(view(onSendAnnotation)); await userEvent.click(page.getByRole("button", { name: "Annotate preview" })); const sent = { ...picked, screenshot: null }; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9423fed3835b..f1da5beffd69 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,5 +1,3 @@ -"use client"; - import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { previewStreamDownloadUrl } from "@t3tools/client-runtime/preview/server-browser-stream"; diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 73ffdbd769f3..b4fe378354fd 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -1,5 +1,3 @@ -"use client"; - import { FILL_PREVIEW_VIEWPORT, type ScopedThreadRef } from "@t3tools/contracts"; import { previewPdfAssetUrl } from "@t3tools/shared/preview"; import { useEnvironmentHttpBaseUrl } from "~/state/environments"; diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts deleted file mode 100644 index 5d2b78ce4cc5..000000000000 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - DEFAULT_BROWSER_PROFILE_ID, - DEFAULT_CLIENT_SETTINGS, - FILL_PREVIEW_VIEWPORT, - type PreviewOpenInput, - type PreviewSessionSnapshot, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - applyPreviewServerSnapshot, - readThreadPreviewState, - resetPreviewStateForTests, -} from "~/previewStateStore"; -import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; -import { __setClientSettingsForTests } from "~/hooks/useSettings"; - -import { addBrowserSurface } from "./addBrowserSurface"; - -const threadRef = { - environmentId: "local" as ScopedThreadRef["environmentId"], - threadId: "thread-1" as ScopedThreadRef["threadId"], -}; - -const snapshot = (tabId: string): PreviewSessionSnapshot => ({ - threadId: threadRef.threadId, - tabId, - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: `2026-06-18T19:00:0${tabId.at(-1) ?? "0"}.000Z`, -}); - -beforeEach(() => { - __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); - resetPreviewStateForTests(); - useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} }); -}); - -describe("addBrowserSurface", () => { - it("opens under the requested profile", async () => { - const openPreview = vi.fn(async (_input: PreviewOpenInput) => - AsyncResult.success(snapshot("tab-1")), - ); - - await addBrowserSurface({ - threadRef, - openPreview: ({ input }) => openPreview(input), - profileId: "profile-work", - }); - - expect(openPreview).toHaveBeenCalledWith({ - threadId: "thread-1", - viewport: FILL_PREVIEW_VIEWPORT, - profileId: "profile-work", - }); - }); - - it("creates another preview session when a browser tab is already active", async () => { - const first = snapshot("tab-1"); - const second = snapshot("tab-2"); - applyPreviewServerSnapshot(threadRef, first); - useRightPanelStore.getState().openBrowser(threadRef, first.tabId); - const openPreview = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(second)); - - await addBrowserSurface({ threadRef, openPreview: ({ input }) => openPreview(input) }); - - expect(openPreview).toHaveBeenCalledWith({ - threadId: "thread-1", - viewport: FILL_PREVIEW_VIEWPORT, - profileId: DEFAULT_BROWSER_PROFILE_ID, - }); - expect(Object.keys(readThreadPreviewState(threadRef).sessions)).toEqual(["tab-1", "tab-2"]); - expect( - selectThreadRightPanelState( - useRightPanelStore.getState().byThreadKey, - threadRef, - ).surfaces.map((surface) => surface.id), - ).toEqual(["browser:tab-1", "browser:tab-2"]); - }); -}); diff --git a/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts b/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts deleted file mode 100644 index fa01bc138afe..000000000000 --- a/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { agentBrowserCursorOpacity } from "./agentBrowserCursorLogic"; - -describe("agentBrowserCursorOpacity", () => { - it("keeps active movement fully visible", () => { - expect(agentBrowserCursorOpacity(true, "agent")).toBe(1); - expect(agentBrowserCursorOpacity(true, "human")).toBe(1); - }); - - it("settles to a visible idle state", () => { - expect(agentBrowserCursorOpacity(false, "none")).toBe(0.35); - expect(agentBrowserCursorOpacity(false, "agent")).toBe(0.35); - }); - - it("dims further while the human controls the page", () => { - expect(agentBrowserCursorOpacity(false, "human")).toBe(0.18); - }); -}); diff --git a/apps/web/src/components/preview/closePreviewSession.test.ts b/apps/web/src/components/preview/closePreviewSession.test.ts index 75c1f19f20d5..c763c1949460 100644 --- a/apps/web/src/components/preview/closePreviewSession.test.ts +++ b/apps/web/src/components/preview/closePreviewSession.test.ts @@ -3,7 +3,6 @@ import type { PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -70,28 +69,4 @@ describe("closePreviewSession", () => { ]); expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" }); }); - - it("restores the last snapshot and keeps full history when the server close fails", async () => { - applyPreviewServerSnapshot(threadRef, snapshot); - for (let index = 0; index < 20; index++) { - useClosedViewStore.getState().remember({ - kind: "browser", - threadRef, - snapshot: { ...snapshot, tabId: `closed-${index}` }, - }); - } - const history = useClosedViewStore.getState().entries; - - const result = await closePreviewSession({ - closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))), - snapshot, - tabId: snapshot.tabId, - threadRef, - }); - - expect(result._tag).toBe("Failure"); - expect(useClosedViewStore.getState().entries).toEqual(history); - expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); - expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot }); - }); }); diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts deleted file mode 100644 index 3848c503c237..000000000000 --- a/apps/web/src/components/preview/fileExplorerLabel.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - revealInFileExplorerLabel, - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./fileExplorerLabel"; - -describe("revealInFileExplorerLabel", () => { - it.each([ - ["MacIntel", "Reveal in Finder"], - ["Win32", "Reveal in File Explorer"], - ["Linux x86_64", "Reveal in Files"], - ])("maps %s to %s", (platform, expected) => { - expect(revealInFileExplorerLabel(platform)).toBe(expected); - }); -}); - -describe("revealInFileExplorerLabelForOs", () => { - it.each([ - ["darwin", "Reveal in Finder"], - ["windows", "Reveal in File Explorer"], - ["linux", "Reveal in Files"], - ["unknown", "Reveal in Files"], - ] as const)("maps %s to %s", (os, expected) => { - expect(revealInFileExplorerLabelForOs(os)).toBe(expected); - }); -}); - -describe("revealInFileExplorerLabelForKind", () => { - it.each([ - ["finder", "Reveal in Finder"], - ["file-explorer", "Reveal in File Explorer"], - ["files", "Reveal in Files"], - ] as const)("maps %s to %s", (kind, expected) => { - expect(revealInFileExplorerLabelForKind(kind)).toBe(expected); - }); -}); diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index acd4f5cc4a63..edd04a0661c3 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,8 +1,5 @@ import { - DEFAULT_BROWSER_PROFILE_ID, DEFAULT_CLIENT_SETTINGS, - FILL_PREVIEW_VIEWPORT, - type PreviewOpenInput, type PreviewSessionSnapshot, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -52,61 +49,6 @@ afterEach(() => { }); describe("openPreviewSession", () => { - it("creates an idle tab without recording a recently visited URL", async () => { - const idleSnapshot: PreviewSessionSnapshot = { - ...snapshot, - tabId: "tab-blank", - navStatus: { _tag: "Idle" }, - }; - const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(idleSnapshot)); - - await openPreviewSession({ - openPreview: ({ input }) => open(input), - threadRef, - }); - - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - viewport: FILL_PREVIEW_VIEWPORT, - profileId: DEFAULT_BROWSER_PROFILE_ID, - }); - expect(readThreadPreviewState(threadRef).snapshot).toEqual(idleSnapshot); - expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); - }); - - it("applies the RPC response without waiting for a preview event", async () => { - const open = vi.fn(async (_input: PreviewOpenInput) => AsyncResult.success(snapshot)); - - await openPreviewSession({ - openPreview: ({ input }) => open(input), - threadRef, - url: "t3.chat", - }); - - expect(open).toHaveBeenCalledWith({ - threadId: "thread-1", - url: "t3.chat", - viewport: FILL_PREVIEW_VIEWPORT, - profileId: DEFAULT_BROWSER_PROFILE_ID, - }); - expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); - expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual(["https://t3.chat/"]); - }); - - it("returns failures without mutating preview state", async () => { - const failure = new Error("preview unavailable"); - - const result = await openPreviewSession({ - openPreview: async () => AsyncResult.failure(Cause.fail(failure)), - threadRef, - url: "t3.chat", - }); - - expect(result._tag).toBe("Failure"); - expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); - expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); - }); - it.each(["session", "link"] as const)( "does not open a %s with unread settings and uses the saved profile on retry", async (entryPoint) => { @@ -149,16 +91,7 @@ describe("openPreviewSession", () => { }); describe("openUrlInPreview from a link", () => { - it("opens under the source tab's profile instead of the default", async () => { - const openPreview = vi.fn(async (_arg: { input: PreviewOpenInput }) => - AsyncResult.success(snapshot), - ); - - await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/", profileId: "work" }); - - expect(openPreview.mock.calls[0]?.[0].input.profileId).toBe("work"); - }); - + // regression: #16961 a background link open switched the active preview tab it("keeps the current tab active for a background open", async () => { applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); @@ -192,34 +125,4 @@ describe("openUrlInPreview from a link", () => { expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-other"); }); - - it("keeps the new tab when the user picks it while a background open is in flight", async () => { - applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); - setActivePreviewTab(threadRef, "tab-current"); - - await openUrlInPreview({ - openPreview: async () => { - applyPreviewServerSnapshot(threadRef, snapshot); - useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); - return AsyncResult.success(snapshot); - }, - threadRef, - url: "https://t3.chat/", - background: true, - }); - - expect(readThreadPreviewState(threadRef).activeTabId).toBe(snapshot.tabId); - }); - - it("activates the new tab for a foreground open", async () => { - applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" }); - - await openUrlInPreview({ - openPreview: async () => AsyncResult.success(snapshot), - threadRef, - url: "https://t3.chat/", - }); - - expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-1"); - }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 34c7192cd0e2..245a8542a19a 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,12 +1,9 @@ -import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - openTerminalLinkInPreview, - TerminalLinkPreviewOpenError, -} from "./openTerminalLinkInPreview"; +import { openTerminalLinkInPreview } from "./openTerminalLinkInPreview"; vi.mock("~/previewStateStore", () => ({ applyPreviewServerSnapshot: vi.fn(), @@ -48,15 +45,6 @@ const threadRef = { threadId: "thread-1" as ScopedThreadRef["threadId"], }; -const snapshot: PreviewSessionSnapshot = { - threadId: threadRef.threadId, - tabId: "tab-1", - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-06-20T00:00:00.000Z", -}; - beforeEach(() => { browserDefaultsMocks.resolve.mockReset(); browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); @@ -68,128 +56,6 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { - it.each(["target", "defaults"] as const)( - "does not open either browser when reading %s fails", - async (setting) => { - const failure = new Error("Settings read failed"); - if (setting === "target") { - linkTargetMocks.preference.mockImplementationOnce(() => { - throw failure; - }); - } else { - browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); - } - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - - await expect( - openTerminalLinkInPreview({ - url: "https://example.com/docs", - threadRef, - openPreview, - fallbackToBrowser, - forceBrowser: false, - }), - ).rejects.toBe(failure); - expect(fallbackToBrowser).not.toHaveBeenCalled(); - expect(openPreview).not.toHaveBeenCalled(); - }, - ); - - it("opens in the system browser while that is the configured target", async () => { - linkTargetMocks.preference.mockReturnValue("system"); - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - - await openTerminalLinkInPreview({ - url: "http://localhost:3000/", - threadRef, - openPreview, - fallbackToBrowser, - forceBrowser: false, - }); - - expect(fallbackToBrowser).toHaveBeenCalledOnce(); - expect(openPreview).not.toHaveBeenCalled(); - }); - - it("opens public URLs in-app too, not only local servers", async () => { - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - - await openTerminalLinkInPreview({ - url: "https://example.com/docs", - threadRef, - openPreview, - fallbackToBrowser, - forceBrowser: false, - }); - - expect(openPreview).toHaveBeenCalledOnce(); - expect(fallbackToBrowser).not.toHaveBeenCalled(); - }); - - it("waits for hydrated viewport and profile defaults before opening", async () => { - let hydrate: ((defaults: typeof hydratedDefaults) => void) | undefined; - browserDefaultsMocks.resolve.mockImplementationOnce( - () => - new Promise((resolve) => { - hydrate = resolve; - }), - ); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - - const opening = openTerminalLinkInPreview({ - url: "http://localhost:3000/", - threadRef, - openPreview, - fallbackToBrowser: vi.fn(), - forceBrowser: false, - }); - - await vi.waitFor(() => expect(browserDefaultsMocks.resolve).toHaveBeenCalledOnce()); - expect(openPreview).not.toHaveBeenCalled(); - hydrate?.(hydratedDefaults); - await opening; - - expect(openPreview).toHaveBeenCalledWith({ - environmentId: "local", - input: { - threadId: "thread-1", - url: "http://localhost:3000/", - viewport: hydratedDefaults.viewport, - profileId: hydratedDefaults.profileId, - }, - }); - }); - - it("preserves the complete preview failure cause before falling back", async () => { - const rpcError = new Error("preview unavailable"); - const cause = Cause.combine(Cause.fail(rpcError), Cause.die("preview defect")); - const fallbackToBrowser = vi.fn(); - const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); - - await openTerminalLinkInPreview({ - url: "http://127.0.0.1:5173/", - threadRef, - openPreview: async () => AsyncResult.failure(cause), - fallbackToBrowser, - forceBrowser: false, - }); - - expect(fallbackToBrowser).toHaveBeenCalledOnce(); - expect(reportError).toHaveBeenCalledOnce(); - const error = reportError.mock.calls[0]?.[0]; - expect(error).toBeInstanceOf(TerminalLinkPreviewOpenError); - expect(error).toMatchObject({ - environmentId: "local", - threadId: "thread-1", - targetOrigin: "http://127.0.0.1:5173", - cause, - }); - expect(error.message).not.toContain("preview unavailable"); - }); - it("does not report or fall back when opening the preview is interrupted", async () => { const fallbackToBrowser = vi.fn(); const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -205,20 +71,4 @@ describe("openTerminalLinkInPreview", () => { expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); - - it("opens in the system browser when Ctrl or Command is held", async () => { - const fallbackToBrowser = vi.fn(); - const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - - await openTerminalLinkInPreview({ - url: "https://example.com/docs", - threadRef, - openPreview, - fallbackToBrowser, - forceBrowser: true, - }); - - expect(fallbackToBrowser).toHaveBeenCalledOnce(); - expect(openPreview).not.toHaveBeenCalled(); - }); }); diff --git a/apps/web/src/components/preview/previewActionBus.ts b/apps/web/src/components/preview/previewActionBus.ts index 3018adcd74cb..c6c19defad89 100644 --- a/apps/web/src/components/preview/previewActionBus.ts +++ b/apps/web/src/components/preview/previewActionBus.ts @@ -1,5 +1,3 @@ -"use client"; - export type PreviewAction = | "toggle-panel" | "refresh" diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts deleted file mode 100644 index 3759173d3cc2..000000000000 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; - -const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ - threadId: "thread-1", - tabId: "tab-1", - navStatus, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-06-12T20:00:00.000Z", -}); - -describe("shouldShowPreviewEmptyState", () => { - it("shows quick-open options for a new idle browser tab", () => { - expect(shouldShowPreviewEmptyState(snapshot({ _tag: "Idle" }))).toBe(true); - }); - - it("shows browser content once navigation starts", () => { - expect( - shouldShowPreviewEmptyState( - snapshot({ _tag: "Loading", url: "http://localhost:5173", title: "" }), - ), - ).toBe(false); - }); -}); - -describe("getConfiguredPreviewUrls", () => { - it("collects configured preview URLs from project scripts", () => { - const scripts = [ - { previewUrl: "http://localhost:5173" }, - {}, - { previewUrl: "http://localhost:3000" }, - ] as ProjectScript[]; - - expect(getConfiguredPreviewUrls(scripts)).toEqual([ - "http://localhost:5173", - "http://localhost:3000", - ]); - }); -}); diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index b099d3a6aaf1..4d9eff507ed3 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -6,8 +6,6 @@ import { PREVIEW_MINI_PLAYER_EDGE_GAP, type PreviewMiniPlayerObstacles, resizePreviewMiniPlayer, - resolveDeviceMiniPlayerCornerRadius, - resolveDeviceMiniPlayerSourceSize, resolvePreviewMiniPlayerFrame, resolvePreviewMiniPlayerSourceSize, } from "./previewMiniPlayerLayout"; @@ -25,12 +23,6 @@ const detailsCard = { left: 680, right: 988, bottom: 260 }; const withCard: PreviewMiniPlayerObstacles = { composer, detailsCard }; describe("resolvePreviewMiniPlayerSourceSize", () => { - it("uses the device viewport scaled by zoom", () => { - expect( - resolvePreviewMiniPlayerSourceSize({ _tag: "freeform", width: 390, height: 844 }, null, 2), - ).toEqual({ width: 780, height: 1_688 }); - }); - it("uses the size the fill viewport had when it was floated", () => { expect( resolvePreviewMiniPlayerSourceSize( @@ -42,74 +34,7 @@ describe("resolvePreviewMiniPlayerSourceSize", () => { }); }); -describe("resolveDeviceMiniPlayerSourceSize", () => { - it("stands in with the platform's phone shape until the stream reports a size", () => { - const ios = resolveDeviceMiniPlayerSourceSize("ios", null); - expect(ios.width / ios.height).toBeCloseTo(9 / 19.5); - const android = resolveDeviceMiniPlayerSourceSize("android", null); - expect(android.width / android.height).toBeCloseTo(9 / 20); - }); - - it("turns a rotated screen into a landscape box", () => { - const screen = { width: 1_179, height: 2_556, orientation: "landscape_left" } as const; - expect(resolveDeviceMiniPlayerSourceSize("ios", screen)).toEqual({ - width: 2_556, - height: 1_179, - }); - expect( - resolveDeviceMiniPlayerSourceSize("android", { ...screen, orientation: "portrait" }), - ).toEqual({ width: 1_179, height: 2_556 }); - }); - - it("floats a phone at the minimum width rather than the default box", () => { - expect( - resolvePreviewMiniPlayerFrame({ - width: null, - position: null, - source: resolveDeviceMiniPlayerSourceSize("ios", null), - container, - }), - ).toMatchObject({ width: 240, height: 520 }); - }); -}); - -describe("resolveDeviceMiniPlayerCornerRadius", () => { - it("rounds an Android player like a phone, scaled with its short side", () => { - expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 240, height: 520 })).toBe(34); - expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 520, height: 240 })).toBe(34); - expect(resolveDeviceMiniPlayerCornerRadius("android", { width: 60, height: 130 })).toBe(12); - }); - - it("keeps the frame radius for iOS, whose stream has square corners", () => { - expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 240, height: 520 })).toBe(12); - expect(resolveDeviceMiniPlayerCornerRadius("ios", { width: 720, height: 1_000 })).toBe(12); - }); -}); - describe("resolvePreviewMiniPlayerFrame", () => { - it("opens at the source aspect ratio in the top-right corner", () => { - expect( - resolvePreviewMiniPlayerFrame({ width: null, position: null, source, container }), - ).toEqual({ x: 668, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 320, height: 200 }); - }); - - it("keeps a tall source at the minimum width instead of the default box", () => { - expect( - resolvePreviewMiniPlayerFrame({ - width: null, - position: null, - source: { width: 390, height: 844 }, - container, - }), - ).toEqual({ x: 748, y: PREVIEW_MINI_PLAYER_EDGE_GAP, width: 240, height: 519 }); - }); - - it("derives height from the stored width", () => { - expect( - resolvePreviewMiniPlayerFrame({ width: 480, position: { x: 100, y: 80 }, source, container }), - ).toEqual({ x: 100, y: 80, width: 480, height: 300 }); - }); - it("shrinks to the space above the composer without losing the stored width", () => { const frame = resolvePreviewMiniPlayerFrame({ width: 800, @@ -142,58 +67,11 @@ describe("resolvePreviewMiniPlayerFrame", () => { }), ).toEqual(resized); }); - - it("can grow beyond the source rendered size", () => { - expect( - resolvePreviewMiniPlayerFrame({ - width: 900, - position: { x: 12, y: 12 }, - source: { width: 480, height: 320 }, - container, - }), - ).toMatchObject({ width: 900, height: 600 }); - }); }); describe("resizePreviewMiniPlayer", () => { const start = { x: 300, y: 200, width: 320, height: 200 }; - it("keeps the aspect ratio when dragging the right edge", () => { - expect( - resizePreviewMiniPlayer({ - start, - direction: "east", - delta: { x: 160, y: 0 }, - source, - container, - }), - ).toEqual({ x: 300, y: 200, width: 480, height: 300 }); - }); - - it("anchors the right edge when dragging from the left", () => { - expect( - resizePreviewMiniPlayer({ - start, - direction: "west", - delta: { x: -160, y: 0 }, - source, - container, - }), - ).toEqual({ x: 140, y: 200, width: 480, height: 300 }); - }); - - it("anchors the bottom edge when dragging the top up", () => { - expect( - resizePreviewMiniPlayer({ - start, - direction: "north", - delta: { x: 0, y: -100 }, - source, - container, - }), - ).toEqual({ x: 300, y: 100, width: 480, height: 300 }); - }); - it("does not jump when corner motion crosses opposing dominant axes", () => { const frames = [-99, -100, -101].map((y) => resizePreviewMiniPlayer({ @@ -281,18 +159,6 @@ describe("resizePreviewMiniPlayer", () => { }), ).toEqual({ x: 300, y: 100, width: 461, height: 288 }); }); - - it("respects the minimum size", () => { - expect( - resizePreviewMiniPlayer({ - start, - direction: "southeast", - delta: { x: -300, y: -300 }, - source, - container, - }), - ).toEqual({ x: 300, y: 200, width: 240, height: 150 }); - }); }); describe("resolvePreviewMiniPlayerFrame with the details card", () => { @@ -341,22 +207,6 @@ describe("resolvePreviewMiniPlayerFrame with the details card", () => { describe("clampPreviewMiniPlayerPosition", () => { const player = { width: 360, height: 240 }; - it("keeps a dragged player within the chat viewport", () => { - expect(clampPreviewMiniPlayerPosition({ x: 900, y: -40 }, container, player)).toEqual({ - x: 628, - y: gap, - }); - }); - - it("keeps the player above a growing composer", () => { - expect( - clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, { - composer: { ...composer, height: 160 }, - detailsCard: null, - }), - ).toEqual({ x: 500, y: 288 }); - }); - it("lets the player drop into the margin beside the composer", () => { expect( clampPreviewMiniPlayerPosition( @@ -379,24 +229,6 @@ describe("clampPreviewMiniPlayerPosition", () => { ).toEqual({ x: composer.right + gap, y: 500 }); }); - it("keeps the player out from under the details card", () => { - expect(clampPreviewMiniPlayerPosition({ x: 700, y: 40 }, container, player, withCard)).toEqual({ - x: 628, - y: 260 + gap, - }); - }); - - it("slides sideways out from under the card when that is the shorter move", () => { - expect( - clampPreviewMiniPlayerPosition( - { x: 660, y: 40 }, - container, - { width: 60, height: 150 }, - withCard, - ), - ).toEqual({ x: 680 - gap - 60, y: 40 }); - }); - it("clears the card and the composer together in a tight column", () => { const tight = { composer: { left: 0, right: 1_000, height: 300 }, detailsCard }; expect(clampPreviewMiniPlayerPosition({ x: 700, y: 600 }, container, player, tight)).toEqual({ diff --git a/apps/web/src/components/preview/previewSessionSync.browser.test.ts b/apps/web/src/components/preview/previewSessionSync.browser.test.ts deleted file mode 100644 index ba6a60cdbb44..000000000000 --- a/apps/web/src/components/preview/previewSessionSync.browser.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { - EnvironmentId, - ThreadId, - type PreviewEvent, - type PreviewListResult, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult, Atom } from "effect/reactivity"; -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -// Only the transport is substituted. The hook, atom registry and preview -// store run unchanged, including lazy evaluation and refresh notifications. -type ListResult = AsyncResult.AsyncResult; -type EventResult = AsyncResult.AsyncResult; -const harness = vi.hoisted(() => ({ - lists: new Map< - string, - { - box: { current: ListResult; requests: number }; - atom: Atom.Writable; - } - >(), - events: new Map>(), -})); - -vi.mock("~/env", () => ({ isElectron: true })); - -vi.mock("~/state/preview", async () => { - const { AsyncResult, Atom } = await import("effect/reactivity"); - return { - previewEnvironment: { - list: (target: { input: { threadId: string } }) => { - const key = target.input.threadId; - let entry = harness.lists.get(key); - if (!entry) { - const box: { current: ListResult; requests: number } = { - current: AsyncResult.initial(), - requests: 0, - }; - const atom = Atom.writable( - () => { - box.requests += 1; - return AsyncResult.waiting(box.current); - }, - (ctx, value: ListResult) => { - box.current = value; - ctx.setSelf(value); - }, - ); - entry = { box, atom }; - harness.lists.set(key, entry); - } - return entry.atom; - }, - events: (target: { environmentId: string }) => { - let atom = harness.events.get(target.environmentId); - if (!atom) { - atom = Atom.make(AsyncResult.initial()); - harness.events.set(target.environmentId, atom); - } - return atom; - }, - }, - }; -}); - -import { - applyPreviewServerSnapshot, - applyPreviewDesktopState, - readThreadPreviewState, - reconcilePreviewServerSessions, - resetPreviewStateForTests, -} from "~/previewStateStore"; -import { AppAtomRegistryProvider, appAtomRegistry } from "~/rpc/atomRegistry"; -import { previewEnvironment } from "~/state/preview"; -import { useDesktopHostedPreviewSessions, usePreviewSession } from "./usePreviewSession"; - -let root: Root | null = null; -let container: HTMLDivElement | null = null; -let sequence = 0; -let ref: ScopedThreadRef; - -function SessionSync() { - usePreviewSession(ref); - return null; -} - -const listAtom = () => { - previewEnvironment.list({ environmentId: ref.environmentId, input: { threadId: ref.threadId } }); - return harness.lists.get(ref.threadId)!.atom; -}; -const result = ( - revision: number, - tabIds: string[], - serverEpoch = "epoch-1", -): PreviewListResult => ({ - serverEpoch, - revision, - sessions: tabIds.map((tabId) => ({ - threadId: ref.threadId, - tabId, - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-10-02T00:00:00.000Z", - })), -}); - -function DesktopSessionSync() { - useDesktopHostedPreviewSessions(ref.environmentId); - return null; -} - -async function mount(Component = SessionSync) { - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - await act(async () => { - root!.render(createElement(AppAtomRegistryProvider, null, createElement(Component))); - }); -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - sequence += 1; - ref = { - environmentId: EnvironmentId.make(`preview-sync-${sequence}`), - threadId: ThreadId.make(`thread-sync-${sequence}`), - }; -}); - -afterEach(async () => { - await act(async () => root?.unmount()); - root = null; - container?.remove(); - container = null; - resetPreviewStateForTests(); - vi.unstubAllGlobals(); -}); - -describe("native preview session synchronization", () => { - it("discovers and closes a server tab in a background thread without mounting its chat", async () => { - await mount(DesktopSessionSync); - expect(readThreadPreviewState(ref).sessions).toEqual({}); - previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); - const events = harness.events.get(ref.environmentId)!; - const baseline = result(1, ["agent-tab"]); - const snapshot = baseline.sessions[0]!; - await act(async () => { - appAtomRegistry.set( - events, - AsyncResult.success({ - type: "opened", - threadId: ref.threadId, - tabId: snapshot.tabId, - snapshot, - serverEpoch: baseline.serverEpoch, - revision: baseline.revision, - createdAt: snapshot.updatedAt, - }), - ); - }); - expect(readThreadPreviewState(ref).sessions).toEqual({ "agent-tab": snapshot }); - appAtomRegistry.set(listAtom(), AsyncResult.success(baseline)); - expect(readThreadPreviewState(ref).listLoaded).toBe(true); - expect(readThreadPreviewState(ref).snapshot).toEqual(snapshot); - - appAtomRegistry.set( - events, - AsyncResult.success({ - type: "closed", - threadId: ref.threadId, - tabId: snapshot.tabId, - serverEpoch: baseline.serverEpoch, - revision: 2, - createdAt: snapshot.updatedAt, - }), - ); - expect(readThreadPreviewState(ref).sessions).toEqual({}); - expect(readThreadPreviewState(ref).activeTabId).toBeNull(); - }); - - it("evaluates the lazy list once and restores sessions that predate mounting", async () => { - const atom = listAtom(); - await mount(); - expect(harness.lists.get(ref.threadId)?.box.requests).toBe(1); - appAtomRegistry.set(atom, AsyncResult.success(result(1, ["restored"]))); - expect(readThreadPreviewState(ref).activeTabId).toBe("restored"); - }); - - it("preserves a local tab while cached absence is waiting for a fresh response", async () => { - const atom = listAtom(); - appAtomRegistry.set(atom, AsyncResult.waiting(AsyncResult.success(result(1, [])))); - applyPreviewServerSnapshot(ref, result(1, ["local"]).sessions[0]!); - await mount(); - expect(readThreadPreviewState(ref).activeTabId).toBe("local"); - expect(harness.lists.get(ref.threadId)?.box.requests).toBe(0); - appAtomRegistry.set(atom, AsyncResult.success(result(2, ["local"]))); - expect(readThreadPreviewState(ref).activeTabId).toBe("local"); - appAtomRegistry.set(atom, AsyncResult.success(result(3, []))); - expect(readThreadPreviewState(ref).activeTabId).toBeNull(); - }); - - it("refreshes a completed cache without treating its old absence as authoritative", async () => { - const atom = listAtom(); - appAtomRegistry.set(atom, AsyncResult.success(result(1, []))); - reconcilePreviewServerSessions(ref, result(1, [])); - applyPreviewServerSnapshot(ref, result(1, ["local"]).sessions[0]!); - applyPreviewDesktopState(ref, "local", { - hasWebContents: true, - canGoBack: true, - canGoForward: false, - loading: false, - zoomFactor: 1.5, - pictureInPicture: false, - colorScheme: "system", - audioMuted: false, - audible: false, - controller: "human", - favicon: null, - }); - await mount(); - expect(harness.lists.get(ref.threadId)?.box.requests).toBe(1); - expect(readThreadPreviewState(ref).activeTabId).toBe("local"); - expect(readThreadPreviewState(ref).desktopOverlay?.zoomFactor).toBe(1.5); - appAtomRegistry.set(atom, AsyncResult.success(result(2, ["local"]))); - expect(readThreadPreviewState(ref).activeTabId).toBe("local"); - expect(readThreadPreviewState(ref).desktopOverlay?.canGoBack).toBe(true); - }); - - it("retains newer event state when an older list response completes, then retries the list", async () => { - const atom = listAtom(); - await mount(); - const snapshot = result(2, ["opened"]).sessions[0]!; - previewEnvironment.events({ environmentId: ref.environmentId, input: {} }); - appAtomRegistry.set( - harness.events.get(ref.environmentId)!, - AsyncResult.success({ - type: "opened", - threadId: ref.threadId, - tabId: snapshot.tabId, - snapshot, - serverEpoch: "epoch-1", - revision: 2, - createdAt: snapshot.updatedAt, - }), - ); - await act(async () => appAtomRegistry.set(atom, AsyncResult.success(result(1, [])))); - expect(readThreadPreviewState(ref).activeTabId).toBe("opened"); - expect(readThreadPreviewState(ref).serverRevision).toBe(2); - expect(readThreadPreviewState(ref).listLoaded).toBe(false); - // The overtaken list is not a baseline, so the hook asks again; its - // waiting state must not reconcile, and the fresh answer completes it. - expect(harness.lists.get(ref.threadId)?.box.requests).toBe(2); - expect(readThreadPreviewState(ref).activeTabId).toBe("opened"); - appAtomRegistry.set(atom, AsyncResult.success(result(2, ["opened"]))); - expect(readThreadPreviewState(ref).listLoaded).toBe(true); - expect(readThreadPreviewState(ref).activeTabId).toBe("opened"); - }); - - it("keeps the local snapshot on a completed failure and reconciles a later success", async () => { - const atom = listAtom(); - applyPreviewServerSnapshot(ref, result(1, ["local"]).sessions[0]!); - await mount(); - appAtomRegistry.set(atom, AsyncResult.failure(Cause.die("offline"))); - expect(readThreadPreviewState(ref).activeTabId).toBe("local"); - appAtomRegistry.set(atom, AsyncResult.success(result(2, []))); - expect(readThreadPreviewState(ref).activeTabId).toBeNull(); - }); -}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts index ba1846902324..74a5a8e80917 100644 --- a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -17,21 +17,6 @@ const scannerServer = ( }); describe("mergeServers", () => { - it("returns scanner-only entries unchanged", () => { - const result = mergeServers({ - scanner: [scannerServer({})], - configuredUrls: [], - }); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - host: "localhost", - port: 5173, - requestedUrl: "http://localhost:5173", - source: "scanner", - processName: "vite", - }); - }); - it("enriches a configured entry with live process metadata when scanner sees it", () => { const result = mergeServers({ scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })], @@ -63,23 +48,6 @@ describe("mergeServers", () => { expect(result[0]?.source).toBe("scanner"); }); - it("sorts configured live servers before scanner-only servers", () => { - const result = mergeServers({ - scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })], - configuredUrls: ["http://localhost:8080"], - }); - expect(result.map((s) => `${s.source}:${s.port}`)).toEqual(["configured:8080", "scanner:3000"]); - }); - - it("dedupes by lowercased host", () => { - const result = mergeServers({ - scanner: [scannerServer({ host: "Localhost", port: 5173 })], - configuredUrls: ["http://localhost:5173"], - }); - expect(result).toHaveLength(1); - expect(result[0]?.source).toBe("configured"); - }); - it.each(["127.0.0.1", "0.0.0.0", "[::1]"])( "matches configured loopback alias %s to a live localhost server", (host) => { @@ -128,19 +96,4 @@ describe("mergeServers", () => { expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); }); - - it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => { - const result = mergeServers({ - scanner: [ - scannerServer({ - port: 5173, - url: "https://env-42.example.dev:5173/", - requestedUrl: "http://localhost:5173/", - }), - ], - configuredUrls: [], - }); - expect(result[0]?.url).toBe("https://env-42.example.dev:5173/"); - expect(result[0]?.requestedUrl).toBe("http://localhost:5173/"); - }); }); diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 886260ec596d..b6eb56b821db 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -1,5 +1,3 @@ -"use client"; - import type { DesktopPreviewTabState, PreviewReportStatusInput, diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index a7e21faca8ac..72f9f7305e1f 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -1,5 +1,3 @@ -"use client"; - import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx index 6e589b38e2e3..a9ea6f03ad18 100644 --- a/apps/web/src/components/projectScriptEditor.browser.test.tsx +++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx @@ -1,9 +1,8 @@ +import { act, render, type Screen } from "../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; vi.mock(import("~/state/session"), async (original) => ({ ...(await original()), @@ -21,8 +20,6 @@ import { const onSubmit = vi.fn[0]["onSubmit"]>(); const onClose = vi.fn(); const onDelete = vi.fn(); -let root: Root; -let mounted: boolean; function request(name: string, error?: string): ProjectScriptEditorRequest { return { @@ -32,28 +29,22 @@ function request(name: string, error?: string): ProjectScriptEditorRequest { }; } -const open = (nextRequest: ProjectScriptEditorRequest) => - act(async () => - root.render( - - - , - ), +const open = (screen: Screen, nextRequest: ProjectScriptEditorRequest) => + screen.rerender( + + + , ); const submit = () => document.querySelector("form")!.requestSubmit(); -const button = (label: string) => - [...document.querySelectorAll("button")].find((element) => element.textContent === label)!; const saveButton = () => document.querySelector('button[type="submit"]')!; -const fieldset = () => document.querySelector("fieldset")!; -const messages = () => [...document.querySelectorAll("form p")].map((p) => p.textContent); function deferredSave() { let resolve!: (result: ProjectScriptActionResult) => void; @@ -65,49 +56,23 @@ function deferredSave() { return { promise, resolve, reject }; } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { onSubmit.mockReset(); onClose.mockReset(); onDelete.mockReset(); - root = createRoot(document.createElement("div")); - mounted = true; -}); - -afterEach(async () => { - if (mounted) await act(async () => root.unmount()); - vi.unstubAllGlobals(); -}); +} describe("project action editor save lifecycle", () => { - it("blocks repeated submits and edits until the current save completes", async () => { - const save = deferredSave(); - onSubmit.mockReturnValue(save.promise); - await open(request("build")); - - await act(async () => { - submit(); - submit(); - }); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(saveButton().disabled).toBe(true); - expect(saveButton().textContent).toBe("Saving…"); - expect(fieldset().disabled).toBe(true); - expect(button("Cancel").disabled).toBe(false); - - await act(async () => save.resolve(AsyncResult.success(undefined))); - expect(onClose).toHaveBeenCalledTimes(1); - }); - it("does not close a replacement request or release its in-flight save", async () => { + setup(); + await using screen = await render(null); const first = deferredSave(); const second = deferredSave(); onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); - await open(request("build")); + await open(screen, request("build")); await act(async () => submit()); - await open(request("test")); + await open(screen, request("test")); expect(saveButton().disabled).toBe(false); expect(document.querySelector("#script-name")!.value).toBe("test"); await act(async () => submit()); @@ -120,60 +85,4 @@ describe("project action editor save lifecycle", () => { expect(onClose).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); }); - - it.each(["failure", "rejection"] as const)( - "ignores a stale %s after the request changes", - async (outcome) => { - const save = deferredSave(); - onSubmit.mockReturnValue(save.promise); - await open(request("build")); - await act(async () => submit()); - - await open(request("test", "New request error")); - await act(async () => { - if (outcome === "failure") - save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); - else save.reject(new Error("Old save error")); - }); - - expect(messages()).toContain("New request error"); - expect(messages()).not.toContain("Old save error"); - expect(saveButton().disabled).toBe(false); - expect(onClose).not.toHaveBeenCalled(); - }, - ); - - it("shows a current save error and allows retry", async () => { - onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); - onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); - await open(request("build")); - - await act(async () => saveButton().click()); - expect(messages()).toContain("Save failed"); - expect(saveButton().disabled).toBe(false); - expect(fieldset().disabled).toBe(false); - expect(onClose).not.toHaveBeenCalled(); - - await act(async () => saveButton().click()); - expect(onSubmit).toHaveBeenCalledTimes(2); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { - const save = deferredSave(); - onSubmit.mockReturnValue(save.promise); - await open(request("build")); - await act(async () => submit()); - - await act(async () => { - if (exit === "cancel") button("Cancel").click(); - else { - root.unmount(); - mounted = false; - } - }); - onClose.mockClear(); - await act(async () => save.resolve(AsyncResult.success(undefined))); - expect(onClose).not.toHaveBeenCalled(); - }); }); diff --git a/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx b/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx deleted file mode 100644 index eb49960876ee..000000000000 --- a/apps/web/src/components/projectScriptEditor.permissions.browser.test.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { - AuthOrchestrationOperateScope, - AuthSettingsWriteScope, - EnvironmentId, - type AuthEnvironmentScope, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -const permissions = vi.hoisted(() => ({ - canWriteSettings: false, - canEditProject: true, - listeners: new Set<() => void>(), -})); - -vi.mock(import("~/state/session"), async (original) => { - const { useSyncExternalStore } = await import("react"); - const read = (scope: AuthEnvironmentScope) => - scope === AuthOrchestrationOperateScope - ? permissions.canEditProject - : permissions.canWriteSettings; - return { - ...(await original()), - readEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) => read(scope), - useEnvironmentScope: (_id: EnvironmentId | null, scope: AuthEnvironmentScope) => - useSyncExternalStore( - (listener) => { - permissions.listeners.add(listener); - return () => permissions.listeners.delete(listener); - }, - () => read(scope), - ), - }; -}); - -/** A grant change that React has already rendered, unlike a bare assignment. */ -const publish = () => act(async () => permissions.listeners.forEach((listener) => listener())); -import { - ProjectScriptEditorDialog, - type NewProjectScriptInput, - type ProjectScriptEditorRequest, -} from "./projectScriptEditor"; - -const request: ProjectScriptEditorRequest = { - scriptId: "test", - initial: { - name: "Test", - command: "vp test", - icon: "test", - runOnWorktreeCreate: false, - waitForSetup: false, - runOnSettle: false, - keybinding: "mod+k", - previewUrl: null, - autoOpenPreview: false, - }, -}; - -describe("ProjectScriptEditorDialog", () => { - let root: Root; - - beforeEach(() => { - root = createRoot(document.createElement("div")); - permissions.canWriteSettings = false; - permissions.canEditProject = true; - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - async function openEditor( - onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)), - editorRequest = request, - actions = { onDelete: vi.fn(), onClose: vi.fn() }, - editScope: - | typeof AuthOrchestrationOperateScope - | typeof AuthSettingsWriteScope = AuthOrchestrationOperateScope, - ) { - await act(async () => - root.render( - , - ), - ); - } - - const submit = () => act(async () => document.querySelector("form")!.requestSubmit()); - async function click(label: string) { - const button = await vi.waitFor(() => { - const match = [...document.querySelectorAll("button")].find( - (element) => element.textContent === label, - ); - expect(match, label).toBeDefined(); - return match!; - }); - await act(async () => button.click()); - } - const confirmation = () => document.querySelector('[role="alertdialog"]'); - async function pressInShortcutField(keys: string) { - document.querySelector("#script-keybinding")!.focus(); - await userEvent.keyboard(keys); - } - - it.each([null, "test"])( - "saves a settings action without task access (id: %s)", - async (scriptId) => { - permissions.canEditProject = false; - permissions.canWriteSettings = true; - const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit, { ...request, scriptId }, undefined, AuthSettingsWriteScope); - permissions.canWriteSettings = false; - await submit(); - expect(onSubmit).not.toHaveBeenCalled(); - permissions.canWriteSettings = true; - await submit(); - expect(onSubmit).toHaveBeenCalledExactlyOnceWith( - scriptId, - expect.objectContaining({ command: "vp test" }), - ); - }, - ); - - it("rechecks settings access before deleting a settings action", async () => { - permissions.canEditProject = false; - permissions.canWriteSettings = true; - const actions = { onDelete: vi.fn(), onClose: vi.fn() }; - await openEditor(undefined, request, actions, AuthSettingsWriteScope); - await click("Delete"); - permissions.canWriteSettings = false; - await click("Delete action"); - expect(actions.onDelete).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(confirmation()).toBeNull()); - permissions.canWriteSettings = true; - await publish(); - await click("Delete"); - await click("Delete action"); - expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId); - }); - - it("clears an old shortcut when adding an action with no shortcut", async () => { - permissions.canWriteSettings = true; - let persistedKeybinding: string | null = "mod+k"; - const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { - if (input.keybinding !== undefined) persistedKeybinding = input.keybinding; - return AsyncResult.success(undefined); - }); - await openEditor(onSubmit, { - scriptId: null, - initial: { ...request.initial, keybinding: null }, - }); - - await submit(); - - expect(persistedKeybinding).toBeNull(); - expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null })); - }); - - it.each([false, true])( - "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)", - async (revokeBeforeSubmit) => { - permissions.canWriteSettings = revokeBeforeSubmit; - const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; - const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { - if (input.keybinding !== undefined && !permissions.canWriteSettings) { - return AsyncResult.failure( - Cause.fail(new Error("This connection cannot change keyboard shortcuts.")), - ); - } - persisted.name = input.name; - if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; - return AsyncResult.success(undefined); - }); - await openEditor(onSubmit, { - scriptId: null, - initial: { ...request.initial, keybinding: null }, - }); - - permissions.canWriteSettings = false; - await submit(); - - expect(persisted).toEqual({ name: "Test", keybinding: "mod+k" }); - expect(onSubmit).toHaveBeenCalledOnce(); - }, - ); - - it.each([false, true])( - "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)", - async (revokeBeforeSubmit) => { - permissions.canWriteSettings = true; - vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); - const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; - const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { - persisted.name = input.name; - if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; - return AsyncResult.success(undefined); - }); - await openEditor(onSubmit, { - scriptId: null, - initial: { ...request.initial, keybinding: null }, - }); - await pressInShortcutField("{Control>}j{/Control}"); - if (revokeBeforeSubmit) permissions.canWriteSettings = false; - await submit(); - - expect(persisted).toEqual( - revokeBeforeSubmit - ? { name: null, keybinding: "mod+k" } - : { name: "Test", keybinding: "mod+j" }, - ); - }, - ); - - it.each([false, true])( - "preserves a concurrent shortcut change when saving only the script (settings access: %s)", - async (canWriteSettings) => { - permissions.canWriteSettings = canWriteSettings; - const persisted = { name: request.initial.name, keybinding: request.initial.keybinding }; - const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { - persisted.name = input.name; - if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; - return AsyncResult.success(undefined); - }); - await openEditor(onSubmit); - - persisted.keybinding = "mod+j"; - const name = document.querySelector("#script-name")!; - await userEvent.clear(name); - await userEvent.type(name, "Renamed"); - await submit(); - - expect(persisted).toEqual({ name: "Renamed", keybinding: "mod+j" }); - expect(onSubmit).toHaveBeenCalledOnce(); - }, - ); - - it.each([false, true])( - "enforces settings access when submitting an explicit shortcut clear (revoked: %s)", - async (revokeBeforeSubmit) => { - permissions.canWriteSettings = true; - const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit); - await pressInShortcutField("{Backspace}"); - if (revokeBeforeSubmit) permissions.canWriteSettings = false; - await submit(); - - if (revokeBeforeSubmit) { - expect(onSubmit).not.toHaveBeenCalled(); - } else { - expect(onSubmit).toHaveBeenCalledWith( - "test", - expect.objectContaining({ keybinding: null }), - ); - } - }, - ); - - it.each([false, true])( - "requires project access to save even with settings access (revoked: %s)", - async (revokeBeforeSubmit) => { - permissions.canEditProject = revokeBeforeSubmit; - permissions.canWriteSettings = true; - const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); - await openEditor(onSubmit); - - permissions.canEditProject = false; - await submit(); - expect(onSubmit).not.toHaveBeenCalled(); - - permissions.canEditProject = true; - await submit(); - expect(onSubmit).toHaveBeenCalledOnce(); - }, - ); - - it("rechecks project access before deleting from an already open confirmation", async () => { - const actions = { onDelete: vi.fn(), onClose: vi.fn() }; - await openEditor(undefined, request, actions); - await click("Delete"); - - permissions.canEditProject = false; - await click("Delete action"); - expect(actions.onDelete).not.toHaveBeenCalled(); - expect(actions.onClose).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(confirmation()).toBeNull()); - - permissions.canEditProject = true; - await publish(); - await click("Delete"); - await click("Delete action"); - expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId); - expect(actions.onClose).toHaveBeenCalledOnce(); - }); -}); diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx new file mode 100644 index 000000000000..1c7323f3bb0f --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -0,0 +1,371 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentId, + PullRequestAction, + PullRequestDetail, + PullRequestMergeMethod, + PullRequestRef, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + ArrowUpRightIcon, + BookOpenIcon, + CopyIcon, + HammerIcon, + LinkIcon, + MessageCircleQuestionIcon, + MoreHorizontalIcon, + RotateCcwIcon, + SparklesIcon, + Trash2Icon, +} from "lucide-react"; + +import { shortcutLabelForCommand } from "~/keybindings"; +import { readLocalApi } from "~/localApi"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { Button } from "../ui/button"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuShortcut, + MenuTrigger, +} from "../ui/menu"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ActOnEnvironmentPicker, type PerformPullRequestAction } from "./PullRequestDetailActions"; +import { + PULL_REQUEST_MERGE_METHOD_LABELS, + pullRequestActionMenuHasGroup, +} from "./pullRequestDetail.logic"; +import { PullRequestGlyph } from "./pullRequestIcons"; +import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic"; +import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; +import { useInlineActionConfirm } from "./useInlineActionConfirm"; + +/** Everything else the reader can do with the pull request, behind the header's overflow button. */ +export function PullRequestActionsMenu({ + environmentId, + reference, + pullRequestKey, + detail, + linkThreadRef, + onThreadPickerOpenChange, + refreshing, + refreshFromHost, + handoff, + attachesToComposer, + askAboutPullRequest, + explainPullRequest, + canFixFindings, + startFixFindings, + handoffLabels, + canWriteGuide, + guideRunning, + guideWritten, + writeGuide, + pickableEnvironments, + actingEnvironmentId, + onActingEnvironmentChange, + can, + autoMergeArmed, + showsDraftToggle, + showsMergeNow, + showsAutoMerge, + showsMergeMethods, + allowedMergeMethods, + selectedMergeMethod, + onSelectMergeMethod, + showsDeleteBranch, + environmentConnected, + confirmDeleteBranch, + pendingAction, + perform, + openOnHostText, + copyReference, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + pullRequestKey: string; + detail: Pick; + linkThreadRef: ScopedThreadRef | null; + onThreadPickerOpenChange: (open: boolean) => void; + refreshing: boolean; + refreshFromHost: () => Promise; + handoff: string | null; + attachesToComposer: boolean; + askAboutPullRequest: () => void; + explainPullRequest: () => void; + canFixFindings: boolean; + startFixFindings: () => void; + handoffLabels: { readonly fixFindings: string }; + canWriteGuide: boolean; + guideRunning: boolean; + guideWritten: boolean; + writeGuide: () => void; + pickableEnvironments: ReadonlyArray; + actingEnvironmentId: EnvironmentId; + onActingEnvironmentChange: (environmentId: EnvironmentId) => void; + can: (action: PullRequestAction) => boolean; + autoMergeArmed: boolean; + showsDraftToggle: boolean; + showsMergeNow: boolean; + showsAutoMerge: boolean; + showsMergeMethods: boolean; + allowedMergeMethods: ReadonlyArray; + selectedMergeMethod: PullRequestMergeMethod; + onSelectMergeMethod: (method: PullRequestMergeMethod) => void; + showsDeleteBranch: boolean; + environmentConnected: boolean; + confirmDeleteBranch: () => Promise; + pendingAction: PullRequestAction | null; + perform: PerformPullRequestAction; + openOnHostText: string; + copyReference: (value: string, label: string) => void; +}) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const inlineConfirm = useInlineActionConfirm(pullRequestKey); + const actionPending = pendingAction !== null; + return ( + + + + } + > + {refreshing ? ( + + ) : ( + + )} + + } + /> + + {refreshing ? "Refreshing pull request" : "More pull request actions"} + + + + + void refreshFromHost()}> + + Refresh + + + + + {handoff === "ask" ? "Opening..." : "Ask a question"} + + {attachesToComposer + ? "Adds the pull request to this thread's composer." + : "Opens a thread that knows which pull request you mean."} + + + + + + + {handoff === "explain" ? "Opening..." : "Explain this PR"} + + A walk through the diff and what to read closely. + + + + + + {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} + + {canWriteGuide ? ( + + + {guideRunning ? "Writing guide..." : !guideWritten ? "Write guide" : "Rewrite guide"} + + ) : null} + {pickableEnvironments.length > 0 ? ( + + ) : null} + + {detail.state === "open" ? ( + <> + {showsDraftToggle ? ( + void perform(detail.isDraft ? "ready" : "draft")} + > + {detail.isDraft ? ( + + ) : ( + + )} + {detail.isDraft ? "Ready for review" : "Convert to draft"} + + ) : null} + {showsMergeNow ? ( + void perform("merge", selectedMergeMethod))} + > + + {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} + + ) : null} + {autoMergeArmed && can("disable-auto-merge") ? ( + void perform("disable-auto-merge")}> + + Disable auto-merge + + ) : showsAutoMerge ? ( + void perform("enable-auto-merge", selectedMergeMethod), + )} + > + + {inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : "Enable auto-merge"} + + ) : null} + {showsMergeMethods ? ( + <> + {showsDraftToggle || showsMergeNow || showsAutoMerge ? : null} + onSelectMergeMethod(method as PullRequestMergeMethod)} + > + {allowedMergeMethods.map((method) => ( + + + + {PULL_REQUEST_MERGE_METHOD_LABELS[method]} + + + ))} + + + ) : null} + {pullRequestActionMenuHasGroup( + showsDraftToggle, + showsAutoMerge || showsMergeNow, + showsMergeMethods, + ) ? ( + + ) : null} + + ) : null} + void readLocalApi()?.shell.openExternal(detail.url)}> + + {openOnHostText} + + copyReference(detail.url, "PR link")}> + + Copy link + + {shortcutLabelForCommand(keybindings, "thread.copyReference")} + + + copyReference(`#${reference.number}`, "PR number")}> + + Copy PR number + + {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} + + + {detail.state === "open" && can("close") ? ( + <> + + void perform("close"))} + > + + {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} + + + ) : detail.state === "closed" && can("reopen") ? ( + <> + + void perform("reopen")}> + + Reopen pull request + + + ) : detail.state === "merged" && can("revert") ? ( + <> + + void perform("revert"))} + > + + {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} + + + ) : null} + {showsDeleteBranch ? ( + <> + + void confirmDeleteBranch()} + > + + + + {pendingAction === "delete-branch" ? "Deleting branch..." : "Delete branch"} + + + {environmentConnected ? detail.headBranch : "Reconnect to delete this branch"} + + + + + ) : null} + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx b/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx new file mode 100644 index 000000000000..489f989dda91 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestBaseBranch.tsx @@ -0,0 +1,137 @@ +import type { PullRequestUpdateMethod } from "@t3tools/contracts"; +import { TriangleAlertIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestGlyph } from "./pullRequestIcons"; + +interface BaseFreshness { + readonly behindBy: number | null; + readonly methods: ReadonlyArray; +} + +function PullRequestBaseFreshnessWarning({ + baseBranch, + freshness, + pending, + onUpdate, + iconClassName, + className, + children, +}: { + readonly baseBranch: string; + readonly freshness: BaseFreshness; + readonly pending: boolean; + readonly onUpdate: (method: PullRequestUpdateMethod) => void; + readonly iconClassName?: string | undefined; + readonly className?: string; + readonly children?: ReactNode; +}) { + const behind = + freshness.behindBy === null + ? "" + : ` by ${freshness.behindBy.toLocaleString()} ${ + freshness.behindBy === 1 ? "commit" : "commits" + }`; + const summary = `This branch is out-of-date with ${baseBranch}${behind}.`; + return ( + + + } + > + {children} + + + +

{summary}

+

Changes can be cleanly merged.

+ {freshness.methods.length > 0 ? ( + + {freshness.methods.map((method) => ( + + ))} + + ) : null} +
+
+ ); +} + +/** The base branch, warning when the pull request has fallen behind it. */ +export function PullRequestBaseBranch({ + baseBranch, + freshness, + stacked, + pending, + onUpdate, + iconClassName, +}: { + readonly baseBranch: string; + readonly freshness: BaseFreshness | null; + readonly stacked: boolean; + readonly pending: boolean; + readonly onUpdate: (method: PullRequestUpdateMethod) => void; + readonly iconClassName?: string; +}) { + const label = ( + <> + {stacked ? ( + + ) : null} + + + + + ); + if (freshness) { + return ( + + {label} + + ); + } + return ( + + + {label} + + } + /> + {stacked ? `Stacked on ${baseBranch}` : baseBranch} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx deleted file mode 100644 index a397e5d04f45..000000000000 --- a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.browser.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -describe("disabled candidate picker", () => { - it.each(["keyboard", "pointer"])( - "shows the refusal reason for %s without opening the picker", - async (input) => { - const onOpenChange = vi.fn(); - const onSelect = vi.fn(); - await act(async () => { - root.render( - {}} - searchLabel="Search reviewers" - isPending={false} - error={null} - candidates={["alex"]} - emptyLabel="No reviewers" - noMatchLabel="No matching reviewers" - errorLabel="Unable to load reviewers" - truncated={false} - truncatedLabel="More reviewers available" - candidateKey={(candidate) => candidate} - disabled={false} - onSelect={onSelect} - > - {(candidate) => candidate} - , - ); - }); - const trigger = container.querySelector( - 'button[aria-label="Request reviewers"]', - )!; - if (input === "keyboard") { - await act(() => userEvent.tab()); - expect(document.activeElement).toBe(trigger); - } else { - await act(() => userEvent.hover(trigger)); - } - await expect - .poll(() => document.querySelector('[data-slot="tooltip-popup"][data-open]')?.textContent) - .toContain("Only collaborators can request reviewers."); - await act(async () => { - trigger.focus(); - await userEvent.keyboard("{Enter}"); - await userEvent.keyboard(" "); - // Playwright skips aria-disabled targets; the click must still land to prove it is inert. - await userEvent.click(trigger, { force: true }); - }); - expect(onOpenChange).not.toHaveBeenCalled(); - expect(onSelect).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx b/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx index cba047e2a416..e4d6765fd20d 100644 --- a/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChangeBreakdownStat.tsx @@ -13,12 +13,15 @@ const KIND_LABELS = { tests: "Tests", mocks: "Mocks", generated: "Generated", + docs: "Docs", } satisfies Record, string>; +type DiffTotals = { additions: number; deletions: number }; + /** * The pull request's diff stat, with a hover split into code, tests, mocks, and generated files. - * The split is read on first pointer or focus intent, so a page nobody hovers costs the host - * nothing. + * In lists the split is read on first pointer or focus intent, so rows nobody hovers cost the + * host nothing. A view of one pull request passes `eager` so the split is ready before the hover. */ export function PullRequestChangeBreakdownStat({ environmentId, @@ -26,14 +29,16 @@ export function PullRequestChangeBreakdownStat({ additions, deletions, className, + eager = false, }: { environmentId: EnvironmentId; reference: PullRequestRef; additions: number; deletions: number; className: string; + eager?: boolean; }) { - const [requested, setRequested] = useState(false); + const [requested, setRequested] = useState(eager); const query = useEnvironmentQuery( requested ? pullRequestEnvironment.changeBreakdown({ environmentId, input: reference }) : null, ); @@ -56,21 +61,7 @@ export function PullRequestChangeBreakdownStat({ /> {breakdown ? ( -
- {(Object.keys(KIND_LABELS) as Array).map((kind) => { - const { additions, deletions } = breakdown[kind]; - return additions === 0 && deletions === 0 ? null : ( - - ); - })} -
- -
+ ) : query.isPending ? ( Loading breakdown @@ -79,15 +70,61 @@ export function PullRequestChangeBreakdownStat({ ); } -function BreakdownRow({ - label, - additions, - deletions, +/** + * The split shown inline, for surfaces that already sit behind a hover, such as the thread + * details card. It reads on mount, so it costs the host a request only once the card opens. + */ +export function PullRequestChangeBreakdownRows({ + environmentId, + reference, + className, }: { - label: string; - additions: number; - deletions: number; + environmentId: EnvironmentId; + reference: PullRequestRef; + className: string; }) { + const query = useEnvironmentQuery( + pullRequestEnvironment.changeBreakdown({ environmentId, input: reference }), + ); + if (!query.data) return null; + return ( +
+ +
+ ); +} + +function ChangeBreakdownGrid({ + breakdown, + total, +}: { + breakdown: NonNullable; + total?: DiffTotals; +}) { + return ( +
+ {(Object.keys(KIND_LABELS) as Array).map((kind) => { + const { additions, deletions } = breakdown[kind]; + return additions === 0 && deletions === 0 ? null : ( + + ); + })} + {total ? ( + <> +
+ + + ) : null} +
+ ); +} + +function BreakdownRow({ label, additions, deletions }: DiffTotals & { label: string }) { return ( <> {label} diff --git a/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx b/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx new file mode 100644 index 000000000000..ce18229c09cf --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestCompactFilterMenu.tsx @@ -0,0 +1,93 @@ +import { ChevronDownIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + PullRequestFilterOptionIcon, + type PullRequestFilterOption, +} from "./PullRequestListFilters"; + +export function PullRequestCompactFilterMenu({ + label, + triggerIcon, + triggerLabel, + outlined = false, + iconOnly = false, + value, + options, + onChange, + className, +}: { + label: string; + triggerIcon?: ReactNode; + triggerLabel?: string; + outlined?: boolean; + iconOnly?: boolean; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; + className?: string; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + ) : ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx new file mode 100644 index 000000000000..de1af5536fb6 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailActions.tsx @@ -0,0 +1,443 @@ +import type { + EnvironmentId, + PullRequestAction, + PullRequestMergeMethod, + PullRequestUpdateMethod, +} from "@t3tools/contracts"; +import { CheckIcon, ChevronDownIcon, FolderGit2Icon, GitBranchIcon, PlayIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { AnimatedWidth } from "~/components/AnimatedWidth"; +import { cn } from "~/lib/utils"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { resolvePullRequestPrimaryControl } from "./pullRequestDetail.logic"; +import { PullRequestGlyph } from "./pullRequestIcons"; +import type { resolvePullRequestState } from "./pullRequestPresentation"; +import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic"; +import { useInlineActionConfirm } from "./useInlineActionConfirm"; + +export type PerformPullRequestAction = ( + action: PullRequestAction, + method?: PullRequestMergeMethod, + updateMethod?: PullRequestUpdateMethod, +) => Promise; + +export function ActOnEnvironmentPicker({ + environments, + value, + onChange, + disabled, +}: { + environments: ReadonlyArray; + value: EnvironmentId; + onChange: (environmentId: EnvironmentId) => void; + disabled: boolean; +}) { + return ( + <> + + onChange(environmentId as EnvironmentId)} + > + {environments.map((environment) => ( + + + + {environment.label} + + + ))} + + + ); +} + +export function PullRequestCheckoutMenu({ + handoff, + checkoutRoot, + canPrepareWorktree, + canCheckoutHere, + onCheckout, + pickableEnvironments, + actingEnvironmentId, + onActingEnvironmentChange, +}: { + handoff: string | null; + checkoutRoot: string | null; + canPrepareWorktree: boolean; + canCheckoutHere: boolean; + onCheckout: (mode: "worktree" | "local") => void; + pickableEnvironments: ReadonlyArray; + actingEnvironmentId: EnvironmentId; + onActingEnvironmentChange: (environmentId: EnvironmentId) => void; +}) { + return ( + + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + + + onCheckout("worktree")}> + + + In a separate worktree + + Its own folder and thread. Nothing you have open moves. + + + + onCheckout("local")}> + + + In this repository + + Switches the branch you are working in, like `gh pr checkout`. + + + + {pickableEnvironments.length > 0 ? ( + + ) : null} + + + ); +} + +export function PullRequestResolveConflictsButton({ + handoff, + unavailable, + onResolve, +}: { + handoff: string | null; + unavailable: boolean; + onResolve: () => void; +}) { + return ( + + + + + } + /> + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + + ); +} + +/** The header's one prominent control, which depends on where the pull request stands. */ +export function PullRequestPrimaryAction({ + pullRequestKey, + primaryAction, + autoMergeArmed, + armedAutoMergeLabel, + pendingAutoMergeLabel, + selectedMergeMethod, + selectedMergeMethodLabel, + statePresentation, + pendingAction, + perform, + resolveConflicts, +}: { + pullRequestKey: string; + primaryAction: ReturnType | null; + autoMergeArmed: boolean; + armedAutoMergeLabel: string; + pendingAutoMergeLabel: string; + selectedMergeMethod: PullRequestMergeMethod; + selectedMergeMethodLabel: string; + statePresentation: ReturnType | null; + pendingAction: PullRequestAction | null; + perform: PerformPullRequestAction; + resolveConflicts: ReactNode; +}) { + const inlineConfirm = useInlineActionConfirm(pullRequestKey); + const actionPending = pendingAction !== null; + return ( + <> + {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( + + + + {armedAutoMergeLabel} + + } + /> + + {armedAutoMergeLabel}: the host will merge this on its own once its requirements are met + + + ) : null} + {primaryAction === "resolve" ? ( + resolveConflicts + ) : primaryAction === "ready" ? ( + + + + + } + /> + Ready for review + + ) : primaryAction === "enable-auto-merge" ? ( + + + + + } + /> + + {pendingAction === "enable-auto-merge" + ? "Enabling..." + : inlineConfirm.isArmed("enable-auto-merge") + ? "Confirm auto-merge?" + : pendingAutoMergeLabel} + + + ) : primaryAction === "auto-merge-armed" ? ( + + + + {armedAutoMergeLabel} + + } + /> + + {armedAutoMergeLabel}: the host will merge this on its own once its requirements are met + + + ) : primaryAction === "merge" ? ( + + + + + } + /> + + {pendingAction === "merge" + ? "Merging..." + : inlineConfirm.isArmed("merge") + ? "Confirm merge?" + : selectedMergeMethodLabel} + + + ) : (primaryAction === "merged" || primaryAction === "closed") && + statePresentation !== null ? ( + + + + {statePresentation.label} + + + ) : null} + + ); +} + +export function PullRequestApproveWorkflowsButton({ + pullRequestKey, + pendingAction, + perform, +}: { + pullRequestKey: string; + pendingAction: PullRequestAction | null; + perform: PerformPullRequestAction; +}) { + const inlineConfirm = useInlineActionConfirm(pullRequestKey); + const actionPending = pendingAction !== null; + return ( + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : inlineConfirm.isArmed("approve-workflows") + ? "Confirm approve?" + : "Approve workflows to run"} + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx b/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx new file mode 100644 index 000000000000..2441cf3ce5e8 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx @@ -0,0 +1,193 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; + +interface CrumbsPullRequest { + readonly url: string; + readonly provider: string; + readonly number: number; + readonly repository: string; + readonly title: string; +} + +const openNumberContextMenu = (event: ReactMouseEvent, detail: CrumbsPullRequest): void => { + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url: detail.url, + openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)), + position: { x: event.clientX, y: event.clientY }, + }); +}; + +const ROW_CLASS_NAME = + "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none sm:text-xs"; + +function BackButton({ onBack, focusable }: { onBack: () => void; focusable: boolean }) { + return ( + + + + + } + /> + Back to pull requests + + ); +} + +function NumberLink({ + detail, + toneClassName, + openOnHostText, + focusable, +}: { + detail: CrumbsPullRequest; + toneClassName: string; + openOnHostText: string; + focusable: boolean; +}) { + return ( + + void readLocalApi()?.shell.openExternal(detail.url)} + onAuxClick={(event) => { + if (event.button === 1) void readLocalApi()?.shell.openExternal(detail.url); + }} + onContextMenu={(event) => openNumberContextMenu(event, detail)} + className={cn( + "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", + toneClassName, + )} + aria-label={`Open pull request #${detail.number} on host`} + > + #{detail.number} + + + } + /> + {openOnHostText} + + ); +} + +/** + * The repository and number above the title, swapped for the number and title once the reader has + * scrolled the title away. + */ +export function PullRequestDetailCrumbs({ + detail, + toneClassName, + condensed, + repositoryUrl, + openOnHostText, + onBack, +}: { + detail: CrumbsPullRequest | null; + toneClassName: string | null; + condensed: boolean; + repositoryUrl: string | null; + openOnHostText: string; + onBack: (() => void) | undefined; +}) { + const ready = detail !== null && toneClassName !== null; + return ( +
+
+ {ready ? ( + <> + {onBack ? : null} + + void readLocalApi()?.shell.openExternal(repositoryUrl)} + className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" + > + {detail.repository} + + ) : ( + + {detail.repository} + + ) + } + /> + + {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} + + + + + ) : null} +
+
+ {ready ? ( + <> + {onBack ? : null} + + + + {detail.title} + + } + /> + {detail.title} + + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 91d7eb25a166..e4601fd1bef4 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,9 +1,6 @@ -import { readLocalApi } from "~/localApi"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; -import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { AnimatedWidth } from "~/components/AnimatedWidth"; import { scopedThreadKey, scopeProjectRef, @@ -30,39 +27,20 @@ import { import { ArrowDownUpIcon, ArrowLeftIcon, - ArrowUpRightIcon, - BookOpenIcon, - CheckIcon, CircleDotIcon, - CopyIcon, - ChevronDownIcon, - ExternalLinkIcon, FileDiffIcon, - FolderGit2Icon, - GitBranchIcon, GitCommitHorizontalIcon, - HammerIcon, - MessageCircleQuestionIcon, MessageSquareIcon, - LinkIcon, - MoreHorizontalIcon, PanelRightIcon, - PlayIcon, - RotateCcwIcon, - SparklesIcon, - Trash2Icon, - TriangleAlertIcon, } from "lucide-react"; import { lazy, Suspense, - type MouseEvent as ReactMouseEvent, useEffect, useEffectEvent, useLayoutEffect, useRef, useState, - type ReactNode, } from "react"; import { @@ -71,36 +49,27 @@ import { useComposerDraftStore, } from "~/composerDraftStore"; import { useComposerSubmitRequestStore } from "~/composerSubmitRequestStore"; +import type { PullRequestAgentDelivery } from "./PullRequestCodeTab"; import { type PullRequestDetailTab, useRightPanelStore } from "~/rightPanelStore"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "~/commandPaletteBus"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - type ShortcutMatchContext, -} from "~/keybindings"; +import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; -import { useLinkClickHandler, useOpenLink } from "~/browser/useOpenLink"; import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions"; import { OFF_HEAD_PULL_REQUEST_CHECKOUT_TOAST } from "./pullRequestCheckoutToast"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { - pullRequestEnvironment, - pullRequestListEntryToSummary, - newestPullRequestSummary, - usePullRequestTurnRefresh, - useSharedPullRequestSummary, -} from "~/state/pullRequests"; +import { pullRequestEnvironment, usePullRequestTurnRefresh } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { PullRequestStackMenu } from "./PullRequestStackMenu"; import { PullRequestChangeBreakdownStat } from "./PullRequestChangeBreakdownStat"; @@ -110,22 +79,7 @@ import { vcsEnvironment } from "~/state/vcs"; import { RelativeTime } from "~/components/RelativeTime"; import { useUiStateStore } from "~/uiStateStore"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { PullRequestEditButton } from "./PullRequestEditButton"; -import { Input } from "../ui/input"; -import { - Menu, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuShortcut, - MenuTrigger, -} from "../ui/menu"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; @@ -133,28 +87,33 @@ import { PullRequestChecksStatusLine, PullRequestDetailHeaderBody, PullRequestDetailTabBar, - PullRequestDetailTitleRow, } from "./PullRequestDetailLayout"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; +import { pullRequestRefKey, usePullRequestDetail } from "./usePullRequestDetail"; +import { PullRequestActionsMenu } from "./PullRequestActionsMenu"; +import { + PullRequestApproveWorkflowsButton, + PullRequestCheckoutMenu, + PullRequestPrimaryAction, + PullRequestResolveConflictsButton, +} from "./PullRequestDetailActions"; +import { PullRequestBaseBranch } from "./PullRequestBaseBranch"; +import { PullRequestDetailCrumbs } from "./PullRequestDetailCrumbs"; +import { PullRequestTitleEditor } from "./PullRequestTitleEditor"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; -import type { - PullRequestAgentDelivery, - PullRequestAgentSelectionInput, -} from "./PullRequestCodeTab"; -import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; +import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; +import { openOnHostLabel } from "./pullRequestLinkContextMenu"; import { PullRequestMarkdownContext } from "./PullRequestMarkdownContext"; import { PullRequestComposer } from "./PullRequestComposer"; -import { PullRequestQuoteToolbar, type PullRequestQuote } from "./PullRequestQuoteToolbar"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { buildAddSelectionToAgentHandoff, buildAskAboutGuideStopHandoff, buildAskAboutPullRequestHandoff, - buildPullRequestQuoteContext, buildExplainPullRequestHandoff, buildFixFindingHandoff, buildFixFindingsHandoff, @@ -165,7 +124,6 @@ import { loadingPullRequestCheckoutCommand, isPullRequestNotFound, isStackedPullRequestBase, - pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestCheckoutCommand, pullRequestFindingKey, @@ -173,9 +131,7 @@ import { PULL_REQUEST_MERGE_METHOD_LABELS, checkoutFailureDescription, readableFailure, - readPullRequestDetailSnapshot, resolvePullRequestReferenceHost, - resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, allowsSinglePullRequestMerge, canDeletePullRequestBranch, @@ -184,13 +140,8 @@ import { type PullRequestFinding, shouldRefreshPullRequestActivity, stripPullRequestHandoffReferences, - writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; -import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; -import { - resolvePickableEnvironments, - type PickableEnvironment, -} from "./pullRequestProjectAssignment.logic"; +import { resolvePickableEnvironments } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { PullRequestActorLabel, @@ -202,11 +153,9 @@ import { resolvePullRequestState, summarizePullRequestChecks, } from "./pullRequestPresentation"; -import { PullRequestGlyph } from "./pullRequestIcons"; import { PullRequestGuideTab } from "./PullRequestGuideTab"; import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore"; import { useReviewGuideWriter } from "./guide/useReviewGuideWriter"; -import { useInlineActionConfirm } from "./useInlineActionConfirm"; const ACTION_FAILURE_LABELS: Record = { merge: "Could not merge this pull request", @@ -265,150 +214,9 @@ const PullRequestCodeTab = lazy(loadCodeTab); */ const lastHandoffPromptByDraft = new Map(); -/** - * What an ask left in the composer. "Ask" leaves the prompt empty on purpose, so saying the - * question is in it would send the reader looking for something that is not there, unless a chip - * carries their remark. A send that found a draft already there stops short of sending that too. - */ -function askLandedDescription(asked: boolean, delivery: PullRequestAgentDelivery): string { - if (delivery === "send") { - return "The composer already held a draft, so nothing was sent. Read it over, then send."; - } - if (delivery === "draft") { - return "The composer already held a draft, so nothing was saved. Read it over, then save or send."; - } - return asked - ? "The question is in the composer — read it over, then send." - : "The pull request is in the composer — type your question, then send."; -} - const composerTargetKey = (target: ScopedThreadRef | DraftId): string => typeof target === "string" ? target : scopedThreadKey(target); -function ActOnEnvironmentPicker({ - environments, - value, - onChange, - disabled, -}: { - environments: ReadonlyArray; - value: EnvironmentId; - onChange: (environmentId: EnvironmentId) => void; - disabled: boolean; -}) { - return ( - <> - - onChange(environmentId as EnvironmentId)} - > - {environments.map((environment) => ( - - - - {environment.label} - - - ))} - - - ); -} - -const openNumberContextMenu = ( - event: ReactMouseEvent, - detail: { readonly url: string; readonly provider: string }, - openLink: (url: string) => Promise, -): void => { - event.preventDefault(); - event.stopPropagation(); - void showPullRequestLinkContextMenu({ - url: detail.url, - openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)), - openLink, - position: { x: event.clientX, y: event.clientY }, - }); -}; - -function PullRequestBaseFreshnessWarning({ - baseBranch, - freshness, - pending, - onUpdate, - iconClassName, - className, - children, -}: { - readonly baseBranch: string; - readonly freshness: { - readonly behindBy: number | null; - readonly methods: ReadonlyArray; - }; - readonly pending: boolean; - readonly onUpdate: (method: PullRequestUpdateMethod) => void; - readonly iconClassName?: string; - readonly className?: string; - readonly children?: ReactNode; -}) { - const behind = - freshness.behindBy === null - ? "" - : ` by ${freshness.behindBy.toLocaleString()} ${ - freshness.behindBy === 1 ? "commit" : "commits" - }`; - const summary = `This branch is out-of-date with ${baseBranch}${behind}.`; - return ( - - - } - > - {children} - - - -

{summary}

-

Changes can be cleanly merged.

- {freshness.methods.length > 0 ? ( - - {freshness.methods.map((method) => ( - - ))} - - ) : null} -
-
- ); -} - function reportCheckoutCommandError(error: Error) { toastManager.add({ type: "error", @@ -431,162 +239,6 @@ function resolvePullRequestReference( }; } -function pullRequestRefKey(reference: PullRequestRef) { - return `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; -} - -function pullRequestDetailSummary(data: PullRequestDetail | null) { - return data === null ? null : { ...data, checksState: pullRequestChecksState(data.checks) }; -} - -/** - * Loads a pull request's detail and activity and merges them, with the snapshot cache, the shared - * summary and the list row, into the detail the panel shows. - */ -function usePullRequestDetail( - environmentId: EnvironmentId, - reference: PullRequestRef, - listEntry: PullRequestListEntry | null, -) { - const scopeKey = `${environmentId}:${pullRequestRefKey(reference)}`; - const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); - const detailQuery = useEnvironmentQuery( - pullRequestEnvironment.detail({ environmentId, input: reference }), - ); - const activityQuery = useEnvironmentQuery( - pullRequestEnvironment.activity({ environmentId, input: reference }), - ); - const [cachedDetail, setCachedDetail] = useState(() => - readPullRequestDetailSnapshot( - typeof window === "undefined" ? undefined : window.localStorage, - environmentId, - reference, - ), - ); - const [cachedDetailKey, setCachedDetailKey] = useState(scopeKey); - if (cachedDetailKey !== scopeKey) { - setCachedDetailKey(scopeKey); - setCachedDetail( - detailQuery.data ?? - readPullRequestDetailSnapshot( - typeof window === "undefined" ? undefined : window.localStorage, - environmentId, - reference, - ), - ); - } else if (detailQuery.data !== null && detailQuery.data !== cachedDetail) { - setCachedDetail(detailQuery.data); - } - const saveDetailSnapshot = useEffectEvent((data: PullRequestDetail) => { - writePullRequestDetailSnapshot( - typeof window === "undefined" ? undefined : window.localStorage, - environmentId, - reference, - data, - ); - }); - useEffect(() => { - if (detailQuery.data !== null) saveDetailSnapshot(detailQuery.data); - }, [detailQuery.data]); - const detailSummary = pullRequestDetailSummary(detailQuery.data); - const observedSummary = useSharedPullRequestSummary( - environmentId, - reference, - detailSummary, - detailQuery.dataUpdatedAt, - ); - const activity = activityQuery.data; - const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ - live: detailQuery.data, - cached: cachedDetail, - reference, - }); - // Every hook runs above this line: a hook between the merge and the panel's plain readers of - // `detail` would cost it its cache scope. - const listSummary = listEntry === null ? null : pullRequestListEntryToSummary(listEntry); - // The list row is also published to the shared cache, but only after this commit's layout - // effects run, so it is compared directly rather than trusted to be there already. - const sharedSummary = newestPullRequestSummary( - resolvedCoreDetail, - newestPullRequestSummary(observedSummary, listSummary), - ); - const coreDetail = - resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail - ? resolvedCoreDetail - : { - ...resolvedCoreDetail, - title: sharedSummary.title, - state: sharedSummary.state, - headBranch: sharedSummary.headBranch, - baseBranch: sharedSummary.baseBranch, - updatedAt: sharedSummary.updatedAt, - author: sharedSummary.author ?? resolvedCoreDetail.author, - additions: sharedSummary.additions ?? resolvedCoreDetail.additions, - deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, - changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, - mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, - closedAt: - sharedSummary.closedAt === undefined - ? resolvedCoreDetail.closedAt - : sharedSummary.closedAt, - mergedAt: - sharedSummary.mergedAt === undefined - ? resolvedCoreDetail.mergedAt - : sharedSummary.mergedAt, - isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, - }; - const detail = - coreDetail === null - ? null - : { - ...coreDetail, - capabilities: canWriteSourceControl - ? coreDetail.capabilities - : { - ...coreDetail.capabilities, - reactions: false, - edit: { changeRequest: false, comment: false }, - }, - viewerPermissions: canWriteSourceControl - ? coreDetail.viewerPermissions - : { - ...coreDetail.viewerPermissions, - actions: [], - comment: false, - resolve: false, - verdicts: [], - requestReviewers: false, - updateMethods: [], - labels: false, - }, - author: activity?.author ?? coreDetail.author, - reviewers: activity?.reviewers ?? coreDetail.reviewers, - comments: activity?.comments ?? [], - commentCount: Math.max( - activity?.commentCount ?? 0, - new Set([ - ...(activity?.comments.map((comment) => comment.id) ?? []), - ...(activity?.reviewThreads.flatMap((thread) => - thread.comments.map((comment) => comment.id), - ) ?? []), - ]).size, - ), - commentsTruncated: activity?.commentsTruncated ?? false, - reviewThreads: activity?.reviewThreads ?? [], - commits: activity?.commits ?? [], - reactions: activity?.reactions ?? [], - }; - return { - canWriteSourceControl, - detailQuery, - activityQuery, - activity, - sharedSummary, - coreDetail, - detail, - }; -} - function pullRequestMarkdownContext( hostClient: ReturnType, repositoryUrl: string | null, @@ -609,6 +261,18 @@ function pullRequestStackReference( : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }; } +function askLandedDescription(asked: boolean, delivery: PullRequestAgentDelivery): string { + if (delivery === "send") { + return "The composer already held a draft, so nothing was sent. Read it over, then send."; + } + if (delivery === "draft") { + return "The composer already held a draft, so nothing was saved. Read it over, then save or send."; + } + return asked + ? "The question is in the composer — read it over, then send." + : "The pull request is in the composer — type your question, then send."; +} + export function PullRequestDetailPanel({ environmentId, shortcutsEnabled, @@ -680,7 +344,6 @@ export function PullRequestDetailPanel({ setTab(next); onTabChange?.(next); }; - const [contentViewport, setContentViewport] = useState(null); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ readonly pullRequestKey: string; @@ -780,7 +443,6 @@ export function PullRequestDetailPanel({ const setMergeMethod = (method: PullRequestMergeMethod) => { setMergeMethodSelection({ pullRequestKey, method }); }; - const inlineConfirm = useInlineActionConfirm(pullRequestKey); const [handoff, setHandoff] = useState(null); const turnRefresh = usePullRequestTurnRefresh(environmentId); const { @@ -829,8 +491,6 @@ export function PullRequestDetailPanel({ const hostClient = sourceControlClients.get(detail?.provider); const openOnHostText = openOnHostLabel(hostClient); const markdownContext = pullRequestMarkdownContext(hostClient, repositoryUrl, threadRef); - const onLinkClick = useLinkClickHandler(threadRef); - const openLink = useOpenLink(threadRef); const authorProfileUrl = detail?.author && repositoryUrl !== null ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl) @@ -939,13 +599,6 @@ export function PullRequestDetailPanel({ }); const [pendingAction, setPendingAction] = useState(null); const actionPending = pendingAction !== null; - const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); - const [titleScope, setTitleScope] = useState<{ - readonly pullRequestKey: string; - readonly text: string; - } | null>(null); - const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null; - const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); const pickableEnvironments = @@ -1054,39 +707,12 @@ export function PullRequestDetailPanel({ return { commentPosted: true }; }; - const saveTitle = async (next: string) => { - const title = next.trim(); - if (!canWriteSourceControl || detail === null || titleSaving) return; - if (title.length === 0 || title === detail.title) { - setTitleScope(null); - return; - } - setTitleSaving(true); - const result = await update({ environmentId, input: { ...reference, title } }); - setTitleSaving(false); - if (result._tag === "Failure") { - toastManager.add({ - type: "error", - title: "The title could not be saved", - description: readableFailure( - squashAtomCommandFailure(result), - "The host refused the new title.", - ), - }); - return; - } - setTitleScope(null); - refreshDetail(); - }; - type ThreadTask = { prompt: string; reviewComments?: ReadonlyArray; }; const attachTarget = composerDraftTarget ?? null; - // Drafts live on server threads; a new thread's composer already keeps its text locally. - const canSaveAgentDraft = typeof attachTarget === "object" && attachTarget !== null; const canPrepareWorktree = prepareThread.isAllowed && canOperateThread; const canFixFindings = attachTarget !== null || canPrepareWorktree; const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); @@ -1162,12 +788,8 @@ export function PullRequestDetailPanel({ task: ThreadTask, options?: { readonly keepGuide?: boolean; - /** - * Send it, or keep it as a thread draft, unless the composer holds a draft that would go - * along with it. Left in the composer by default. - */ + /** Send it, or keep it as a thread draft, unless the composer holds a draft that would go along. */ readonly delivery?: PullRequestAgentDelivery; - /** A chip carries the reader's own question, so the empty prompt is not theirs to fill. */ readonly questionInChip?: boolean; }, ) => { @@ -1179,7 +801,6 @@ export function PullRequestDetailPanel({ useComposerDraftStore.getState().getComposerDraft(attachTarget), ); writeTaskToComposer(attachTarget, task); - // The message lands in the thread beside the panel, which says it was sent or saved. if (delivery !== "composer" && !heldDraft) { useComposerSubmitRequestStore .getState() @@ -1424,33 +1045,10 @@ export function PullRequestDetailPanel({ comment: selection.comment, request: selection.request, }), - { ...options, delivery: selection.delivery, questionInChip: true }, + { ...options, delivery: selection.delivery }, ); }; - /** - * A citation lands at the caret of the composer beside the panel, as one from a response does. - * With no composer there, it opens a thread holding it. - */ - const citeQuote = (quote: PullRequestQuote) => { - if (!detail) return; - const chip = buildPullRequestQuoteContext({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - isDraft: detail.isDraft, - quote, - }); - if (attachTarget !== null) { - useComposerDraftStore.getState().addReviewComment(attachTarget, chip); - return; - } - void startAsk(`quote:${chip.id}`, { prompt: "", reviewComments: [chip] }); - }; - const startCheckout = (mode: "worktree" | "local") => { if (!handoffSummary) return; void startHandoff(`checkout:${mode}`, null, mode); @@ -1471,34 +1069,6 @@ export function PullRequestDetailPanel({ ); }; - /** The fix lands in the thread's composer, then waits there as a draft instead of a send. */ - const saveFixFindingAsDraft = (finding: PullRequestFinding) => { - if (!detail || typeof attachTarget !== "object" || attachTarget === null) return; - const heldDraft = composerDraftHasUserContent( - useComposerDraftStore.getState().getComposerDraft(attachTarget), - ); - writeTaskToComposer( - attachTarget, - buildFixFindingHandoff({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - finding, - }), - ); - if (!heldDraft) { - useComposerSubmitRequestStore.getState().request(attachTarget, "save-draft"); - return; - } - toastManager.add({ - type: "success", - title: "Added to the composer", - description: askLandedDescription(true, "draft"), - }); - }; - const startFixFindings = () => { if (!detail) return; void startHandoff( @@ -1529,6 +1099,12 @@ export function PullRequestDetailPanel({ }); }; + const selectMergeMethod = (method: PullRequestMergeMethod) => { + setMergeMethod(method); + setLastSelectedMergeMethod(method); + }; + const selectActingEnvironment = (next: EnvironmentId) => + setActingScope({ pullRequestKey, environmentId: next }); const allowedMergeMethods = detail ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) : []; @@ -1653,85 +1229,24 @@ export function PullRequestDetailPanel({ const checkoutControl = context === "page" ? ( - - - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> - } - /> - Check out this pull request - - - startCheckout("worktree")}> - - - In a separate worktree - - Its own folder and thread. Nothing you have open moves. - - - - startCheckout("local")}> - - - In this repository - - Switches the branch you are working in, like `gh pr checkout`. - - - - {pickableEnvironments.length > 0 ? ( - setActingScope({ pullRequestKey, environmentId: next })} - disabled={handoff !== null} - /> - ) : null} - - + ) : null; const resolveConflictsControl = ( - - - - - } - /> - - {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} - - + ); if (detailQuery.isPending && !detail) { @@ -1739,7 +1254,6 @@ export function PullRequestDetailPanel({ -
-
- {detail && statePresentation ? ( - <> - {onBack ? ( - - - - - } - /> - Back to pull requests - - ) : null} - - onLinkClick(event, repositoryUrl)} - className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" - > - {detail.repository} - - ) : ( - - {detail.repository} - - ) - } - /> - - {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} - - - - onLinkClick(event, detail.url)} - onAuxClick={(event) => { - if (event.button === 1) - void readLocalApi()?.shell.openExternal(detail.url); - }} - onContextMenu={(event) => openNumberContextMenu(event, detail, openLink)} - className={cn( - "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", - statePresentation.toneClassName, - )} - aria-label={`Open pull request #${detail.number} on host`} - > - #{detail.number} - - - } - /> - {openOnHostText} - - - ) : null} -
-
- {detail && statePresentation ? ( - <> - {onBack ? ( - - - - - } - /> - Back to pull requests - - ) : null} - - onLinkClick(event, detail.url)} - onAuxClick={(event) => { - if (event.button === 1) - void readLocalApi()?.shell.openExternal(detail.url); - }} - onContextMenu={(event) => openNumberContextMenu(event, detail, openLink)} - className={cn( - "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", - statePresentation.toneClassName, - )} - aria-label={`Open pull request #${detail.number} on host`} - > - #{detail.number} - - - } - /> - {openOnHostText} - - - - {detail.title} - - } - /> - {detail.title} - - - ) : null} -
-
+
{detail ? ( @@ -1975,454 +1347,63 @@ export function PullRequestDetailPanel({ threadRef={null} /> ) : null} - {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( - - - - {armedAutoMergeLabel} - - } - /> - - {armedAutoMergeLabel}: the host will merge this on its own once its requirements - are met - - - ) : null} - {primaryAction === "resolve" ? ( - resolveConflictsControl - ) : primaryAction === "ready" ? ( - - - - - } - /> - Ready for review - - ) : primaryAction === "enable-auto-merge" ? ( - - - - - } - /> - - {pendingAction === "enable-auto-merge" - ? "Enabling..." - : inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" - : pendingAutoMergeLabel} - - - ) : primaryAction === "auto-merge-armed" ? ( - - - - {armedAutoMergeLabel} - - } - /> - - {armedAutoMergeLabel}: the host will merge this on its own once its requirements - are met - - - ) : primaryAction === "merge" ? ( - - - - - } - /> - - {pendingAction === "merge" - ? "Merging..." - : inlineConfirm.isArmed("merge") - ? "Confirm merge?" - : selectedMergeMethodLabel} - - - ) : (primaryAction === "merged" || primaryAction === "closed") && - statePresentation !== null ? ( - - - - {statePresentation.label} - - - ) : null} + {checkoutControl} - - - - } - > - {refreshing ? ( - - ) : ( - - )} - - } - /> - - {refreshing ? "Refreshing pull request" : "More pull request actions"} - - - - - void refreshFromHost()}> - - Refresh - - - - - {handoff === "ask" ? "Opening..." : "Ask a question"} - - {attachTarget !== null - ? "Adds the pull request to this thread's composer." - : "Opens a thread that knows which pull request you mean."} - - - - - - - {handoff === "explain" ? "Opening..." : "Explain this PR"} - - A walk through the diff and what to read closely. - - - - - - {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} - - {reviewGuidesEnabled && guideWriter.canWrite ? ( - - - {guideWriter.running - ? "Writing guide..." - : guideWriter.data?.guide == null - ? "Write guide" - : "Rewrite guide"} - - ) : null} - {pickableEnvironments.length > 0 ? ( - setActingScope({ pullRequestKey, environmentId: next })} - disabled={handoff !== null} - /> - ) : null} - - {detail.state === "open" ? ( - <> - {showsDraftToggle ? ( - void perform(detail.isDraft ? "ready" : "draft")} - > - {detail.isDraft ? ( - - ) : ( - - )} - {detail.isDraft ? "Ready for review" : "Convert to draft"} - - ) : null} - {showsMergeNow ? ( - void perform("merge", selectedMergeMethod), - )} - > - - {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"} - - ) : null} - {autoMergeArmed && can("disable-auto-merge") ? ( - void perform("disable-auto-merge")} - > - - Disable auto-merge - - ) : showsAutoMerge ? ( - void perform("enable-auto-merge", selectedMergeMethod), - )} - > - - {inlineConfirm.isArmed("enable-auto-merge") - ? "Confirm auto-merge?" - : "Enable auto-merge"} - - ) : null} - {showsMergeMethods ? ( - <> - {showsDraftToggle || showsMergeNow || showsAutoMerge ? ( - - ) : null} - { - const selectedMethod = method as PullRequestMergeMethod; - setMergeMethod(selectedMethod); - setLastSelectedMergeMethod(selectedMethod); - }} - > - {allowedMergeMethods.map((method) => ( - - - - {PULL_REQUEST_MERGE_METHOD_LABELS[method]} - - - ))} - - - ) : null} - {pullRequestActionMenuHasGroup( - showsDraftToggle, - showsAutoMerge || showsMergeNow, - showsMergeMethods, - ) ? ( - - ) : null} - - ) : null} - onLinkClick(event, detail.url)}> - - {openOnHostText} - - copyReference(detail.url, "PR link")}> - - Copy link - - {shortcutLabelForCommand(keybindings, "thread.copyReference")} - - - copyReference(`#${reference.number}`, "PR number")}> - - Copy PR number - - {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} - - - {detail.state === "open" && can("close") ? ( - <> - - void perform("close"))} - > - - {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"} - - - ) : detail.state === "closed" && can("reopen") ? ( - <> - - void perform("reopen")}> - - Reopen pull request - - - ) : detail.state === "merged" && can("revert") ? ( - <> - - void perform("revert"))} - > - - {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"} - - - ) : null} - {showsDeleteBranch ? ( - <> - - void confirmDeleteBranch()} - > - - - - {pendingAction === "delete-branch" - ? "Deleting branch..." - : "Delete branch"} - - - {environmentConnected - ? detail.headBranch - : "Reconnect to delete this branch"} - - - - - ) : null} - - + ) : null} {onClose ? ( @@ -2462,7 +1443,6 @@ export function PullRequestDetailPanel({ @@ -2472,49 +1452,14 @@ export function PullRequestDetailPanel({ - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" - className="max-w-[40%]" - > - {isStackedPullRequest ? ( - - ) : null} - - - - - ) : ( - - - {isStackedPullRequest ? ( - - ) : null} - - - - - } - /> - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - - - )} + void perform("update-branch", undefined, method)} + iconClassName="size-3" + />
@@ -2575,75 +1521,17 @@ export function PullRequestDetailPanel({ {detail ? ( - - - {detail.title} - - } - /> - {detail.title} - - {canEditPullRequestChangeRequest(detail) ? ( - setTitleScope({ pullRequestKey, text: detail.title })} - /> - ) : null} - - ) : ( -
- - setTitleScope({ pullRequestKey, text: event.target.value }) - } - onKeyDown={(event) => { - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - void saveTitle(titleDraft); - } else if (event.key === "Escape") { - event.preventDefault(); - setTitleScope(null); - } - }} - /> -
- - -
-
- ) + } author={ - + } updated={ @@ -2665,48 +1553,13 @@ export function PullRequestDetailPanel({ ) : null } base={ - freshness ? ( - void perform("update-branch", undefined, method)} - className="max-w-[40%]" - > - {isStackedPullRequest ? ( - - ) : null} - - - - - ) : ( - - - {isStackedPullRequest ? ( - - ) : null} - - - - - } - /> - - {isStackedPullRequest - ? `Stacked on ${detail.baseBranch}` - : detail.baseBranch} - - - ) + void perform("update-branch", undefined, method)} + /> } head={ } /> @@ -2743,54 +1597,11 @@ export function PullRequestDetailPanel({ > {tab === "summary" ? ( showsApproveWorkflows ? ( - - - - - } - /> - - {pendingAction === "approve-workflows" - ? "Approving..." - : inlineConfirm.isArmed("approve-workflows") - ? "Confirm approve?" - : "Approve workflows to run"} - - + ) : (
{ const scroller = scrollerByTab.current[tab]; @@ -2920,12 +1730,10 @@ export function PullRequestDetailPanel({ : { error: detailQuery.error })} refreshing={detailQuery.isPending} onRetry={refreshDetail} - threadRef={threadRef} {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} /> ) : detail ? ( - {mountedTabs.has("summary") ? (
@@ -2960,10 +1765,10 @@ export function PullRequestDetailPanel({ canComment={ detail.capabilities.review.inlineComment && detail.viewerPermissions.comment } + canSaveAgentDraft={attachTarget !== null} onAskAboutLines={(comment, request, delivery) => addSelectionToAgent({ comment, request, delivery }, { keepGuide: true }) } - canSaveAgentDraft={canSaveAgentDraft} onAskAboutStop={askAboutGuideStop} onFinishReview={ detail.capabilities.review.verdicts.some((verdict) => @@ -3006,7 +1811,6 @@ export function PullRequestDetailPanel({ void }> - | undefined { - for (const child of Children.toArray(node)) { - if (!isValidElement(child)) continue; - const props = child.props as { - readonly children?: ReactNode; - readonly onValueChange?: (value: string) => void; - }; - if (props.onValueChange) { - return child as ReactElement<{ - readonly children?: ReactNode; - readonly onValueChange: (value: string) => void; - }>; - } - const nested = findValueChange(props.children); - if (nested) return nested; - } - return undefined; -} - -function findLabeledGroup(node: ReactNode, label: string): ReactNode { - for (const child of Children.toArray(node)) { - if (!isValidElement(child)) continue; - const props = child.props as { readonly children?: ReactNode; readonly label?: string }; - if (props.label === label && typeof child.type === "function") { - const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); - return findLabeledGroup(rendered, label) ?? rendered; - } - const nested = findLabeledGroup(props.children, label); - if (nested !== undefined) return nested; - } - return undefined; -} - -function menu(overrides: Partial[0]>) { - return PullRequestFiltersMenu({ - state: "open", - stateOptions: [ - { value: "open", label: "Open", Icon: CircleIcon }, - { value: "closed", label: "Closed", Icon: CircleIcon }, - ], - onState: () => undefined, - involvement: "all", - involvementOptions: [{ value: "all", label: "All", Icon: CircleIcon }], - onInvolvement: () => undefined, - filters: {}, - onFilters: () => undefined, - host: undefined, - hostOptions: [], - onHost: () => undefined, - server: undefined, - serverOptions: [], - onServer: () => undefined, - projects: [], - projectId: undefined, - projectEnvironmentId: undefined, - unavailable: new Map(), - onProject: () => undefined, - ...overrides, - }); -} - -describe("pull request filters menu", () => { - it("does not emit a change when the selected state is chosen again", () => { - const onState = vi.fn(); - const group = findValueChange(findLabeledGroup(menu({ onState }), "State")); - expect(group).toBeDefined(); - - group?.props.onValueChange("open"); - expect(onState).not.toHaveBeenCalled(); - - group?.props.onValueChange("closed"); - expect(onState).toHaveBeenCalledOnce(); - expect(onState).toHaveBeenCalledWith("closed"); - }); - - it("names the chosen narrowing and leaves the others alone", () => { - const onFilters = vi.fn(); - const group = findValueChange( - findLabeledGroup(menu({ filters: { review: "approved" }, onFilters }), "Draft"), - ); - expect(group).toBeDefined(); - - group?.props.onValueChange("hide"); - expect(onFilters).toHaveBeenCalledWith({ review: "approved", draft: "hide" }); - }); - - it("drops a narrowing chosen back to all rather than sending it as undefined", () => { - const onFilters = vi.fn(); - const group = findValueChange( - findLabeledGroup( - menu({ filters: { review: "none", checks: "failing" }, onFilters }), - "Review", - ), - ); - expect(group).toBeDefined(); - - group?.props.onValueChange("all"); - expect(onFilters).toHaveBeenCalledWith({ checks: "failing" }); - }); - - it("does not emit a change when the selected project is chosen again", () => { - const projectId = "project-1" as ProjectId; - const environmentId = "env-1" as EnvironmentId; - const onProject = vi.fn(); - const view = menu({ - projects: [ - { - id: projectId, - environmentId, - title: "T3 Code", - workspaceRoot: "/work/t3code", - }, - ], - projectId, - projectEnvironmentId: environmentId, - onProject, - }); - const radioGroup = findValueChange(findLabeledGroup(view, "Project")); - expect(radioGroup).toBeDefined(); - - radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); - expect(onProject).not.toHaveBeenCalled(); - - radioGroup?.props.onValueChange("all"); - expect(onProject).toHaveBeenCalledWith(undefined, undefined); - }); - - it("passes the environment along so a duplicate project id on another server is told apart", () => { - const projectId = "project-1" as ProjectId; - const onProject = vi.fn(); - const view = menu({ - projects: [ - { - id: projectId, - environmentId: "env-1" as EnvironmentId, - title: "T3 Code · one", - workspaceRoot: "/work/t3code-1", - }, - { - id: projectId, - environmentId: "env-2" as EnvironmentId, - title: "T3 Code · two", - workspaceRoot: "/work/t3code-2", - }, - ], - onProject, - }); - const radioGroup = findValueChange(findLabeledGroup(view, "Project")); - expect(radioGroup).toBeDefined(); - - radioGroup?.props.onValueChange( - pullRequestProjectKey({ id: projectId, environmentId: "env-2" as EnvironmentId }), - ); - expect(onProject).toHaveBeenCalledWith(projectId, "env-2"); - }); - - it("does not collide when environment and project ids contain spaces", () => { - expect( - pullRequestProjectKey({ - environmentId: "a b" as EnvironmentId, - id: "c" as ProjectId, - }), - ).not.toBe( - pullRequestProjectKey({ - environmentId: "a" as EnvironmentId, - id: "b c" as ProjectId, - }), - ); - }); -}); diff --git a/apps/web/src/components/pullRequest/PullRequestListGroups.tsx b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx new file mode 100644 index 000000000000..7e8c4e3a26f1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListGroups.tsx @@ -0,0 +1,118 @@ +import type { EnvironmentId, PullRequestAction } from "@t3tools/contracts"; +import { EyeIcon, LayersIcon, PenLineIcon, UsersIcon, type LucideIcon } from "lucide-react"; +import type { RefCallback } from "react"; + +import { cn } from "~/lib/utils"; +import { Separator } from "../ui/separator"; +import { + pullRequestEntryKey, + scorePullRequestMatch, + type EnvironmentPullRequestEntry, + type PullRequestGroup, +} from "./pullRequestList.logic"; +import { PullRequestRow, type PullRequestRowTarget } from "./PullRequestRow"; +import { + PullRequestSpeedModeProvider, + type PullRequestSpeedActionResult, +} from "./PullRequestSpeedActions"; + +const GROUP_ICONS: Record = { + authored: PenLineIcon, + reviewRequested: EyeIcon, + others: UsersIcon, +}; + +const MATCHED_ELSEWHERE_SCORE = 10; + +function PullRequestGroupHeader({ + group, +}: { + group: { key: string; label: string; entries: ReadonlyArray }; +}) { + const Icon = GROUP_ICONS[group.key] ?? LayersIcon; + return ( +
+ +

{group.label}

+ {group.entries.length} + +
+ ); +} + +export function PullRequestListGroups({ + groups, + environmentLabels, + query, + selected, + showProvider, + statsRef, + onSelect, + hostActionsOf, + onActed, + closingKeys, + sweepingKeys, + onCloseSweepStart, +}: { + groups: ReadonlyArray>; + /** Only when rows come from more than one server, so each says which. */ + environmentLabels: ReadonlyMap | null; + query: string; + selected: { + readonly environmentId: string | undefined; + readonly repository: string; + readonly host: string | undefined; + readonly number: number; + } | null; + showProvider: boolean; + statsRef: RefCallback; + onSelect: (entry: PullRequestRowTarget) => void; + hostActionsOf: (entry: EnvironmentPullRequestEntry) => ReadonlySet; + onActed: (result: PullRequestSpeedActionResult) => void; + closingKeys: ReadonlySet; + sweepingKeys: ReadonlySet; + onCloseSweepStart: (entry: EnvironmentPullRequestEntry, event: PointerEvent) => void; +}) { + return ( + +
0 && "**:pointer-events-none")}> + {groups.map((group) => ( +
+ {group.label ? : null} + {group.entries.map((entry) => { + const entryKey = pullRequestEntryKey(entry); + const environmentLabel = environmentLabels?.get(entry.environmentId); + return ( + 0 && + scorePullRequestMatch(entry, query) <= MATCHED_ELSEWHERE_SCORE + } + selected={ + selected?.environmentId === entry.environmentId && + selected.repository === entry.repository && + selected.host?.toLowerCase() === entry.host.toLowerCase() && + selected.number === entry.number + } + onSelect={onSelect} + hostActions={hostActionsOf(entry)} + onActed={onActed} + closing={closingKeys.has(entryKey)} + sweeping={sweepingKeys.has(entryKey)} + onCloseSweepStart={onCloseSweepStart} + /> + ); + })} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx index 6f6569a0b534..faf7057f97b8 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.browser.test.tsx @@ -1,7 +1,6 @@ +import { act, render, type Screen } from "../../../test/render"; import { AuthSourceControlWriteScope, EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const permissions = vi.hoisted(() => ({ @@ -29,56 +28,41 @@ vi.mock(import("~/state/session"), async (original) => { import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; -const primary = EnvironmentId.make("primary"); const secondary = EnvironmentId.make("secondary"); const onSave = vi.fn<(next: string) => void>(); const onCancel = vi.fn<() => void>(); -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { permissions.allowed.clear(); permissions.listeners.clear(); onSave.mockReset(); onCancel.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} async function openEditor(options?: { value?: string; allowEmpty?: boolean }) { - await act(async () => - root.render( - , - ), + return render( + , ); } -function button(label: string) { - const match = [...container.querySelectorAll("button")].find( +function button(screen: Screen, label: string) { + const match = [...screen.container.querySelectorAll("button")].find( (element) => element.textContent === label, ); expect(match, `button ${label}`).toBeDefined(); return match!; } -const clickSave = () => act(async () => button("Save").click()); +const clickSave = (screen: Screen) => act(async () => button(screen, "Save").click()); async function grant(allowed: boolean) { await act(async () => { @@ -88,72 +72,19 @@ async function grant(allowed: boolean) { }); } -it("does not save a secondary environment's description using the primary grant", async () => { - permissions.allowed.add(primary); - await openEditor(); - - expect(button("Save").disabled).toBe(true); - await clickSave(); - - expect(onSave).not.toHaveBeenCalled(); -}); - -it("rejects a Save click when the grant changes before React updates", async () => { - permissions.allowed.add(secondary); - await openEditor(); - permissions.allowed.delete(secondary); - - expect(button("Save").disabled).toBe(false); - await clickSave(); - - expect(onSave).not.toHaveBeenCalled(); -}); - it("keeps an edited draft through revocation and saves it after access returns without remounting", async () => { + setup(); permissions.allowed.add(secondary); - await openEditor(); - const textarea = container.querySelector("textarea")!; + await using screen = await openEditor(); + const textarea = screen.container.querySelector("textarea")!; await userEvent.clear(textarea); await userEvent.type(textarea, "Keep these unsaved edits"); await grant(false); - expect(button("Save").disabled).toBe(true); - expect(container.querySelector("textarea")).toBe(textarea); + expect(button(screen, "Save").disabled).toBe(true); + expect(screen.container.querySelector("textarea")).toBe(textarea); await grant(true); - await clickSave(); + await clickSave(screen); expect(onSave).toHaveBeenCalledExactlyOnceWith("Keep these unsaved edits"); }); - -it("saves with source-control permission alone, from the button or Cmd/Ctrl+Enter", async () => { - permissions.allowed.add(secondary); - await openEditor(); - - await clickSave(); - expect(onSave).toHaveBeenCalledExactlyOnceWith("Original description"); - - container.querySelector("textarea")!.focus(); - await userEvent.keyboard("{Control>}{Enter}{/Control}"); - expect(onSave).toHaveBeenCalledTimes(2); -}); - -it("allows clearing a description only after a pending grant becomes available", async () => { - await openEditor({ value: "", allowEmpty: true }); - await clickSave(); - expect(onSave).not.toHaveBeenCalled(); - - await grant(true); - await clickSave(); - expect(onSave).toHaveBeenCalledExactlyOnceWith(""); -}); - -it("keeps cancelling a local edit available without a write grant", async () => { - await openEditor(); - - await act(async () => button("Cancel").click()); - container.querySelector("textarea")!.focus(); - await userEvent.keyboard("{Escape}"); - - expect(onCancel).toHaveBeenCalledTimes(2); - expect(onSave).not.toHaveBeenCalled(); -}); diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 2bdbc2886265..2800733917dd 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,6 +1,7 @@ import type { PullRequestAction } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SearchIcon } from "lucide-react"; +import { PullRequestChangeBreakdownStat } from "./PullRequestChangeBreakdownStat"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { PullRequestSpeedActions, @@ -22,11 +23,7 @@ import { PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; -import { - PullRequestDiffStat, - PullRequestLabelChip, - PullRequestReviewDecisionGlyph, -} from "./pullRequestPresentation"; +import { PullRequestLabelChip, PullRequestReviewDecisionGlyph } from "./pullRequestPresentation"; const LABEL_SLOTS = [ { overflow: "@xl/pr-row-meta:hidden" }, @@ -81,7 +78,6 @@ function PullRequestRowImpl({ statsKey, statsRef, onSelect, - speedMode, hostActions, onActed, closing = false, @@ -98,7 +94,6 @@ function PullRequestRowImpl({ statsKey?: string; statsRef?: RefCallback; onSelect: (entry: PullRequestRowTarget) => void; - speedMode: boolean; hostActions: ReadonlySet; onActed: (result: PullRequestSpeedActionResult) => void; closing?: boolean; @@ -186,7 +181,14 @@ function PullRequestRowImpl({ } /> ) : null} - ({ entry, hostActions, - visible, onActed, closing = false, sweeping = false, @@ -34,7 +36,6 @@ export function PullRequestSpeedActions; - visible: boolean; onActed?: (result: PullRequestSpeedActionResult) => void; closing?: boolean; sweeping?: boolean; @@ -75,12 +76,10 @@ export function PullRequestSpeedActions {actions.map((action) => { const label = ACTIONS[action].label; @@ -116,6 +115,45 @@ export function PullRequestSpeedActions ); })} + + ); +} + +/** + * Whether Shift is held, as context rather than a row prop: a Shift press then re-renders each + * row's action group, not the rows or their buttons, in one commit. + */ +export const PullRequestSpeedModeContext = createContext(false); + +/** Tracks Shift for the quick actions below it. */ +export function PullRequestSpeedModeProvider({ children }: { children: ReactNode }) { + const modifiers = useShortcutModifierState(true); + const speedMode = + modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey; + return {children}; +} + +function SpeedModeGroup({ + busy, + pending, + label, + children, +}: { + busy: boolean; + pending: boolean; + label: string; + children: ReactNode; +}) { + const speedMode = use(PullRequestSpeedModeContext); + return ( +
+ {children}
); } diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 1d518deec477..ba8cb353e8d1 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -1,13 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -import { - EnvironmentId, - ProjectId, - type PullRequestDetailView, - type PullRequestComment, -} from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; vi.mock(import("~/state/use-atom-command"), async (original) => ({ @@ -15,6 +8,7 @@ vi.mock(import("~/state/use-atom-command"), async (original) => ({ useAtomCommand: (() => vi.fn()) as never, })); +import { act, render as renderUi } from "../../../test/render"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; const detail: PullRequestDetailView = { @@ -69,217 +63,48 @@ const detail: PullRequestDetailView = { }, }; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - vi.unstubAllGlobals(); -}); - -const render = (value = detail) => - act(async () => - root.render( - {}} - environmentId={EnvironmentId.make("environment")} - threadRef={null} - reference={value} - detail={value} - activityPending={false} - activityError={null} - onRefresh={() => {}} - />, - ), - ); - -function heading(title: string) { - const section = container.querySelector(`section[aria-label="${title}"]`); - expect(section, `section ${title}`).not.toBeNull(); - return [...section!.querySelectorAll("button")].find( - (button) => button.querySelector("span")?.textContent === title, - )!; +// The comment filter persists to localStorage, so tests that touch it start and end empty. + +const tab = (value = detail) => ( + {}} + environmentId={EnvironmentId.make("environment")} + threadRef={null} + reference={value} + detail={value} + activityPending={false} + activityError={null} + onRefresh={() => {}} + /> +); + +async function render(value = detail) { + const screen = await renderUi(tab(value)); + const { container } = screen; + const heading = (title: string) => { + const section = container.querySelector(`section[aria-label="${title}"]`); + expect(section, `section ${title}`).not.toBeNull(); + return [...section!.querySelectorAll("button")].find( + (button) => button.querySelector("span")?.textContent === title, + )!; + }; + return Object.assign(screen, { + click: (title: string) => act(async () => heading(title).click()), + expanded: (title: string) => heading(title).getAttribute("aria-expanded"), + visibleText: () => container.innerText, + }); } -const click = (title: string) => act(async () => heading(title).click()); -const expanded = (title: string) => heading(title).getAttribute("aria-expanded"); -const visibleText = () => container.innerText; - -it("toggles checks from their heading and resets sections for another pull request", async () => { - await render(); - expect(visibleText()).not.toContain("Unit tests"); - await click("Checks"); - expect(visibleText()).toContain("Unit tests"); - await click("Checks"); - expect(expanded("Checks")).toBe("false"); - await click("Checks"); - await click("Description"); - await render({ ...detail, url: "https://github.com/owner/repo/pull/2", number: 2 }); - expect(expanded("Checks")).toBe("false"); - expect(expanded("Description")).toBe("true"); -}); - it("keeps an unsaved description when collapsed and reopened", async () => { - await render(); + await using screen = await render(); await act(async () => - container.querySelector('[aria-label="Edit description"]')!.click(), + screen.container.querySelector('[aria-label="Edit description"]')!.click(), ); - const textarea = container.querySelector("textarea")!; + const textarea = screen.container.querySelector("textarea")!; await userEvent.clear(textarea); await userEvent.type(textarea, "Unsaved description"); - await click("Description"); - expect(expanded("Description")).toBe("false"); - await click("Description"); - expect(container.querySelector("textarea")!.value).toBe("Unsaved description"); -}); - -function commentTexts() { - const section = [...container.querySelectorAll("section")].find((element) => - element.getAttribute("aria-label")?.startsWith("Comments"), - )!; - return [...section.querySelectorAll("p")] - .filter((p) => p.checkVisibility()) - .map((p) => p.textContent ?? ""); -} - -const filterButton = () => - container.querySelector('[aria-label="Filter comments"]')!; - -function filterItem(label: string) { - const item = [...document.querySelectorAll('[role="menuitemcheckbox"]')].find((element) => - element.textContent?.includes(label), - ) as HTMLElement | undefined; - expect(item, `filter ${label}`).toBeDefined(); - return item!; -} - -async function toggleFilter(label: string) { - if (!document.querySelector('[role="menuitemcheckbox"]')) await userEvent.click(filterButton()); - await userEvent.click(filterItem(label)); -} - -it("hides bot and resolved comments until the filter shows them, and remembers the choice", async () => { - const comment = (index: number, login: string, body: string, isBot = false) => ({ - id: `comment-${index}`, - kind: "issue-comment" as const, - author: { login, name: null, avatarUrl: null, isBot }, - body, - createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`, - url: null, - path: null, - reviewState: null, - }); - const resolved = { - ...comment(13, "reviewer", "Resolved remark"), - kind: "review-comment" as const, - }; - const value: PullRequestDetailView = { - ...detail, - commentCount: 14, - comments: [ - ...Array.from({ length: 12 }, (_, index) => - comment(index, "review-app", `Bot report ${index}`, true), - ), - comment(12, "human", "Human comment"), - resolved, - ], - reviewThreads: [ - { - id: "thread", - path: "src/index.ts", - line: 1, - side: "right", - isResolved: true, - isOutdated: false, - comments: [resolved], - }, - ], - }; - await render(value); - expect(commentTexts()).toEqual(["Human comment"]); - expect(filterButton().textContent).toContain("13 hidden"); - - await toggleFilter("Bot comments"); - expect(commentTexts().filter((text) => text.startsWith("Bot report"))).toHaveLength(9); - expect(commentTexts()).toContain("Human comment"); - expect(commentTexts()).not.toContain("Bot report 0"); - - await toggleFilter("Resolved or dismissed"); - await userEvent.keyboard("{Escape}"); - expect( - [...container.querySelectorAll("button")].some((button) => button.textContent === "Resolved"), - ).toBe(true); - - await act(async () => root.unmount()); - root = createRoot(container); - await render({ ...value, url: `${value.url}0`, number: 10 }); - expect(commentTexts()).toContain("Bot report 11"); - await userEvent.click(filterButton()); - expect(filterItem("Bot comments").getAttribute("aria-checked")).toBe("true"); -}); - -it("reveals whole older conversations with honest partial reply counts and a host link", async () => { - const comment = (index: number): PullRequestComment => ({ - id: `comment-${index}`, - kind: "issue-comment", - author: detail.author, - body: `Comment ${index}`, - createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`, - url: `${detail.url}#comment-${index}`, - path: null, - reviewState: null, - }); - const finding = { ...comment(0), kind: "review-comment" as const, body: "Review finding" }; - const reply = { ...comment(12), kind: "review-comment" as const, body: "Loaded reply" }; - const thread = { - id: "partial-thread", - path: "src/index.ts", - line: 1, - side: "right" as const, - isResolved: true, - isOutdated: false, - comments: [finding, reply], - nextCommentsCursor: "next-page", - }; - const value: PullRequestDetailView = { - ...detail, - comments: [finding, ...Array.from({ length: 10 }, (_, index) => comment(index + 1)), reply], - commentCount: 15, - reviewThreads: [{ ...thread, commentCount: 5 }], - }; - await render(value); - await toggleFilter("Resolved or dismissed"); - await userEvent.keyboard("{Escape}"); - const older = [...container.querySelectorAll("button")].find((button) => - button.textContent?.startsWith("Show 1 older"), - )!; - expect(older).toBeDefined(); - expect.soft(older.textContent).toMatch(/1 older conversation.*1 hidden/); - expect(commentTexts()).not.toContain(reply.body); - await userEvent.click(older); - const resolved = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Resolved", - )!; - expect(resolved.getAttribute("aria-expanded")).toBe("false"); - expect(commentTexts()).not.toContain(reply.body); - expect.soft(visibleText()).toContain("4 replies"); - await userEvent.click(resolved); - expect(commentTexts().slice(-2)).toEqual([finding.body, reply.body]); - expect.soft(visibleText()).toContain("3 more replies"); - const hostLink = [...container.querySelectorAll("a")].find( - (link) => - link.href === finding.url && /host/i.test(link.textContent ?? "") && link.checkVisibility(), - ); - expect.soft(hostLink, "visible link to the unloaded replies on the host").toBeDefined(); - - await render({ ...value, reviewThreads: [thread] }); - expect.soft(visibleText()).toMatch(/more replies/i); - expect(visibleText()).not.toContain("3 more replies"); + await screen.click("Description"); + expect(screen.expanded("Description")).toBe("false"); + await screen.click("Description"); + expect(screen.container.querySelector("textarea")!.value).toBe("Unsaved description"); }); diff --git a/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx b/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx new file mode 100644 index 000000000000..735f3e768cc1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx @@ -0,0 +1,127 @@ +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, PullRequestDetail, PullRequestRef } from "@t3tools/contracts"; +import { useState } from "react"; + +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestDetailTitleRow } from "./PullRequestDetailLayout"; +import { PullRequestEditButton } from "./PullRequestEditButton"; +import { readableFailure } from "./pullRequestDetail.logic"; +import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; + +/** The pull request title, which the reader can rename in place when the host allows it. */ +export function PullRequestTitleEditor({ + environmentId, + reference, + pullRequestKey, + detail, + canWriteSourceControl, + onSaved, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + pullRequestKey: string; + detail: PullRequestDetail; + canWriteSourceControl: boolean; + onSaved: () => void; +}) { + const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); + const [titleScope, setTitleScope] = useState<{ + readonly pullRequestKey: string; + readonly text: string; + } | null>(null); + const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null; + const [titleSaving, setTitleSaving] = useState(false); + + const saveTitle = async (next: string) => { + const title = next.trim(); + if (!canWriteSourceControl || titleSaving) return; + if (title.length === 0 || title === detail.title) { + setTitleScope(null); + return; + } + setTitleSaving(true); + const result = await update({ environmentId, input: { ...reference, title } }); + setTitleSaving(false); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "The title could not be saved", + description: readableFailure( + squashAtomCommandFailure(result), + "The host refused the new title.", + ), + }); + return; + } + setTitleScope(null); + onSaved(); + }; + + if (titleDraft === null) { + return ( + + + + {detail.title} + + } + /> + {detail.title} + + {canEditPullRequestChangeRequest(detail) ? ( + setTitleScope({ pullRequestKey, text: detail.title })} + /> + ) : null} + + ); + } + return ( +
+ setTitleScope({ pullRequestKey, text: event.target.value })} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + void saveTitle(titleDraft); + } else if (event.key === "Escape") { + event.preventDefault(); + setTitleScope(null); + } + }} + /> +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestsColumn.tsx b/apps/web/src/components/pullRequest/PullRequestsColumn.tsx new file mode 100644 index 000000000000..9ac06baa4287 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsColumn.tsx @@ -0,0 +1,293 @@ +import type { PullRequestInvolvement, PullRequestListState } from "@t3tools/contracts"; +import { Plug2Icon, SearchIcon } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; + +import { isElectron } from "~/env"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { WorkspacePageContainer } from "../WorkspacePageContainer"; +import { WorkspacePageHeader } from "../WorkspacePageHeader"; +import { PullRequestCompactFilterMenu } from "./PullRequestCompactFilterMenu"; +import type { PullRequestFilterOption } from "./PullRequestListFilters"; +import { INVOLVEMENT_TABS, STATE_TABS } from "./pullRequestListOptions"; + +function ExpandableSearch({ + searchInput, + searchValue, + open, + onOpenChange, + focusToken, + onFocusWithin, +}: { + searchInput: ReactNode; + searchValue: string; + open: boolean; + onOpenChange: (open: boolean) => void; + focusToken: number; + /** + * An unmount fires no blur, which is the point: whoever unmounted this can still see the reader + * was mid-typing and move the focus somewhere that continues the sentence. + */ + onFocusWithin?: (focused: boolean) => void; +}) { + const containerRef = useRef(null); + useEffect(() => { + if (!open) return; + containerRef.current?.querySelector("input")?.focus(); + }, [open]); + const appliedFocusToken = useRef(focusToken); + useEffect(() => { + if (appliedFocusToken.current === focusToken) return; + appliedFocusToken.current = focusToken; + const input = containerRef.current?.querySelector("input"); + input?.focus(); + input?.select(); + }, [focusToken]); + if (open || searchValue.length > 0) { + return ( +
onFocusWithin?.(true)} + onBlur={() => { + onFocusWithin?.(false); + if (searchValue.length === 0) onOpenChange(false); + }} + > + {searchInput} +
+ ); + } + return ( + + ); +} + +export function PullRequestsColumn({ + refreshing, + onRefresh, + searchValue, + involvement, + state, + host, + hostMenuOptions, + onInvolvement, + onState, + onHost, + searchInput, + sortMenu, + filtersMenu, + rightPanelControl, + titlebarControls, + rightPanelOpen, + maximizedAway, + listBody, + scrollRef, +}: { + refreshing: boolean; + onRefresh: () => void; + searchValue: string; + involvement: PullRequestInvolvement; + state: PullRequestListState; + host: string | undefined; + hostMenuOptions: ReadonlyArray>; + onInvolvement: (involvement: PullRequestInvolvement) => void; + onState: (state: PullRequestListState) => void; + onHost: (host: string | undefined) => void; + searchInput: ReactNode; + sortMenu: ReactNode; + filtersMenu: ReactNode; + rightPanelControl: ReactNode; + titlebarControls: ReactNode; + rightPanelOpen: boolean; + maximizedAway: boolean; + listBody: ReactNode; + scrollRef: RefObject; +}) { + const markerRef = useRef(null); + const [condensed, setCondensed] = useState(false); + useEffect(() => { + const marker = markerRef.current; + if (!marker) return; + const observer = new IntersectionObserver( + ([entry]) => setCondensed(entry ? !entry.isIntersecting : false), + { root: scrollRef.current }, + ); + observer.observe(marker); + return () => observer.disconnect(); + }, [scrollRef]); + // Typing into the topbar search narrows the list, and a short enough list un-scrolls the + // page — which dissolves the condensed topbar and unmounts the very input being typed in. + const topbarSearchFocusedRef = useRef(false); + const inFlowSearchRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchFocusToken, setSearchFocusToken] = useState(0); + const searchExpanded = searchOpen || searchValue.length > 0; + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key.toLowerCase() !== "f" || !(event.metaKey || event.ctrlKey)) return; + if (event.altKey || event.shiftKey) return; + event.preventDefault(); + if (condensed) { + setSearchOpen(true); + setSearchFocusToken((token) => token + 1); + return; + } + const input = inFlowSearchRef.current?.querySelector("input"); + input?.focus(); + input?.select(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [condensed]); + if (!condensed && searchOpen) setSearchOpen(false); + useEffect(() => { + if (condensed) return; + if (!topbarSearchFocusedRef.current) return; + topbarSearchFocusedRef.current = false; + const input = inFlowSearchRef.current?.querySelector("input"); + if (!input) return; + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }, [condensed]); + + return ( +
+ + {titlebarControls} + {condensed ? ( + + +

Pull Requests

+
+ {searchExpanded ? null : } + + + + {hostMenuOptions.length > 2 ? ( + onHost(next === "" ? undefined : next)} + /> + ) : null} + +
+ ) : ( + + +

Pull Requests

+
+
+ )} +
+ {condensed ? ( +
+ { + topbarSearchFocusedRef.current = focused; + }} + /> + +
+ ) : null} + {rightPanelControl} + + +
+ +
+
+
+ {searchInput} +
+ {sortMenu} + {filtersMenu} + } + triggerLabel="All" + value={host ?? ""} + options={hostMenuOptions} + onChange={(next) => onHost(next === "" ? undefined : next)} + /> + {!condensed ? ( + + ) : null} +
+
+
+ + {listBody} + +
+
+ ); +} + +function PullRequestRefreshControl({ + compact = false, + refreshing, + onRefresh, +}: { + compact?: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx new file mode 100644 index 000000000000..525150001872 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -0,0 +1,1554 @@ +import { applyPullRequestDraftFilterChange } from "@t3tools/client-runtime/pull-request-query"; +import { Spinner } from "~/components/ui/spinner"; +import type { PullRequestSpeedActionResult } from "~/components/pullRequest/PullRequestSpeedActions"; +import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; +import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; +import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; +import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; +import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import type { + EnvironmentId, + ProjectId, + PullRequestAction, + PullRequestInvolvement, + PullRequestListCursors, + PullRequestListInput, + PullRequestListResult, + PullRequestListState, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { ArrowDownUpIcon, LayersIcon, Plug2Icon } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState, type ComponentProps } from "react"; + +import { + collectPullRequestListFacets, + narrowPullRequestsToFilters, + mergePullRequestDiffStats, + pullRequestEntryKey, + rankPullRequestMatches, + readPullRequestListSnapshot, + writePullRequestListSnapshot, + pullRequestStatsRefreshBatches, + pullRequestStatsRequestBatches, + retainVisiblePullRequestStatsBatches, + type EnvironmentPullRequestEntry, + type MergedPullRequestList, + type PullRequestDiffStats, + type PullRequestStatsBatch, + type PullRequestStatsPolicy, + type PullRequestStatsScope, + type PullRequestPartitionsSnapshot, + type PullRequestListOverride, + pullRequestOverrideAfterAction, + reusePullRequestEntries, + settlePullRequestOverrides, + largestPullRequestReadCount, +} from "./pullRequestList.logic"; +import { + pullRequestListPreferences, + type PullRequestListPreferencePatch, + writePullRequestListPreferences, +} from "./pullRequestListPreferences"; +import { environmentMachineIcon } from "../EnvironmentMachineIcon"; +import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; +import { + PullRequestFiltersMenu, + PullRequestSearchInput, + pullRequestHostLabel, + pullRequestProjectKey, + type PullRequestFilterOption, +} from "./PullRequestListFilters"; +import { PullRequestCompactFilterMenu } from "./PullRequestCompactFilterMenu"; +import { INVOLVEMENT_TABS, SORT_OPTIONS, STATE_TABS } from "./pullRequestListOptions"; +import { PullRequestsColumn } from "./PullRequestsColumn"; +import { PullRequestListGroups } from "./PullRequestListGroups"; +import { + usePullRequestDisplayGroups, + usePullRequestQueryFilters, + usePullRequestScope, + type PullRequestsSearch, +} from "./usePullRequestListView"; +import { pullRequestGroups, pullRequestListEntryId } from "./pullRequestListGroups.logic"; +import { PullRequestListEmptyState } from "./PullRequestListEmptyState"; +import { PullRequestListGhost } from "./PullRequestGhosts"; +import { type PullRequestRowTarget } from "./PullRequestRow"; +import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; +import { RightPanelTabs } from "../RightPanelTabs"; +import { isCommandPaletteOpen } from "~/commandPaletteBus"; +import { isElectron } from "~/env"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "~/keybindings"; +import { isTerminalFocused } from "~/lib/terminalFocus"; +import { PanelLayoutControls, RightPanelMaximizeControl } from "../chat/PanelLayoutControls"; +import { Button } from "../ui/button"; +import { SidebarInset } from "../ui/sidebar"; +import { useLiveRefresh } from "~/hooks/useLiveRefresh"; +import { useStableCallback } from "~/hooks/useStableCallback"; +import { useOpenPanelPullRequestUrl } from "~/hooks/useOpenPanelPullRequestUrl"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { toastManager } from "../ui/toast"; +import { useEscapeToGoBack } from "~/hooks/useNavigateBack"; +import { usePanelAnimationSettings, usePanelPresence } from "~/panelAnimations"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanelSurface, + selectSelectedRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, + type PullRequestSurface, +} from "~/rightPanelStore"; +import { useSidebarProjectGroups } from "~/hooks/useSidebarProjectGroups"; +import { + pullRequestEnvironment, + usePullRequestList, + usePullRequestListStats, + usePullRequestTurnRefreshes, + type EnvironmentQueryTarget, +} from "~/state/pullRequests"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn } from "~/lib/utils"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; + +function getShortcutContext() { + return { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + previewFocus: false, + previewOpen: false, + modelPickerOpen: false, + isWeb: !isElectron, + isDesktop: isElectron, + }; +} + +/** + * One whole page from the host and no more: every provider asks for one row beyond the page as + * its "is there more" probe, and GitHub serves a hundred per request — so asking for ninety-nine + * costs one round trip where a hundred costs two. + */ +const PAGE_SIZE = 99; +const MAX_PAGE_SIZE = 500; +const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; +const EMPTY_PARTITIONS: PullRequestPartitionsSnapshot = { authored: [], reviewing: [] }; +const NO_LIST_TARGETS: ReadonlyArray> = []; +const EMPTY_PREVIEW_SESSIONS = {}; +const EMPTY_PREVIEW_DESKTOP_STATE = {}; +const EMPTY_TERMINAL_LABELS = new Map(); +const EMPTY_PENDING_SURFACES = new Set(); +export function PullRequestsPage() { + useEscapeToGoBack(); + const search = useSearch({ from: "/_chat/pull-requests" }); + const sort = search.sort ?? "ready"; + const statsPolicy: PullRequestStatsPolicy = + sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; + const navigate = useNavigate({ from: "/pull-requests" }); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { + capableEnvironments, + scopedEnvironmentId, + environmentKey, + capabilityKnown, + pullRequestsSupported, + projects, + environmentLabels, + scopedProjectId, + scopedProject, + scopedProjects, + selectedProject, + queryEnvironmentIds, + environmentQueries, + assignmentKey, + linkedSelection, + expectedHosts, + projectsKnown, + } = usePullRequestScope(search); + const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; + const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, rightPanelRef), + ); + const selectedRightPanelSurface = useRightPanelStore((state) => + selectSelectedRightPanelSurface(state.byThreadKey, rightPanelRef), + ); + const selectedPullRequestSurface = + selectedRightPanelSurface?.kind === "pull-request" ? selectedRightPanelSurface : null; + const activePullRequestSurface = rightPanelState.isOpen ? selectedPullRequestSurface : null; + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); + const rightPanelPresenceValue = { + activeSurface: selectedPullRequestSurface, + surfaces: rightPanelState.surfaces, + }; + const rightPanelPresence = usePanelPresence( + rightPanelState.isOpen && selectedPullRequestSurface !== null, + rightPanelPresenceValue, + panelAnimationsActive, + rightPanelRef?.threadId ?? null, + panelAnimationDurationMs, + ); + const rightPanelPresent = rightPanelPresence.present; + const renderedPullRequestSurface = rightPanelPresence.value?.activeSurface ?? null; + const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const panelEnvironmentId = + (renderedPullRequestSurface?.environmentId as EnvironmentId | undefined) ?? + selectedProject?.environmentId ?? + null; + const updateSearch = (patch: { + [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined; + }) => + void navigate({ + search: (previous: PullRequestsSearch): PullRequestsSearch => { + const next = { ...previous, ...patch }; + return { + involvement: next.involvement ?? previous.involvement, + state: next.state ?? previous.state, + ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), + ...(next.repository ? { repository: next.repository } : {}), + ...(next.number ? { number: next.number } : {}), + ...(next.projectId ? { projectId: next.projectId } : {}), + ...(next.environmentId ? { environmentId: next.environmentId } : {}), + ...(next.host ? { host: next.host } : {}), + ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), + ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), + ...(next.selectedEnvironmentId + ? { selectedEnvironmentId: next.selectedEnvironmentId } + : {}), + ...(next.q ? { q: next.q } : {}), + ...(next.draft ? { draft: next.draft } : {}), + ...(next.review ? { review: next.review } : {}), + ...(next.checks ? { checks: next.checks } : {}), + ...(next.author ? { author: next.author } : {}), + ...(next.labels && next.labels.length > 0 ? { labels: next.labels } : {}), + }; + }, + replace: true, + }); + + const clearedSelection = { + repository: undefined, + number: undefined, + selectedProjectId: undefined, + selectedEnvironmentId: undefined, + selectedHost: undefined, + }; + const updateListScope = (patch: PullRequestListPreferencePatch) => { + const currentPreferences = pullRequestListPreferences({ + ...search, + ...patch, + involvement: patch.involvement ?? search.involvement, + state: patch.state ?? search.state, + }); + writePullRequestListPreferences(currentPreferences); + updateSearch(patch); + }; + + const typedQuery = (search.q ?? "").trim(); + const [filtersOpen, setFiltersOpen] = useState(false); + const { + sentQuery, + querySettled, + typedParsed, + sentParsed, + menuFilters, + menuFiltered, + filters, + hasFilters, + localFilters, + hasLocalFilters, + } = usePullRequestQueryFilters(search, typedQuery); + const turnRefreshes = usePullRequestTurnRefreshes( + environmentQueries.map(({ environmentId }) => environmentId), + ); + const turnRefreshToken = turnRefreshes + .map(([environmentId, revision]) => `${environmentId}:${revision}`) + .join("|"); + const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`; + const filterKey = `${scopeKey}:${sentQuery}`; + const statsScope: PullRequestStatsScope = { key: filterKey, policy: statsPolicy }; + const [page, setPage] = useState<{ + key: string; + size: number; + cursors: Readonly> | null; + /** + * An environment can say it has more without saying where to carry on from — its provider has + * no cursor to give — and a continuation naming only the others would strand its rows on the + * host forever. + */ + regrown: ReadonlyArray; + }>({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] }); + const pageSize = page.key === filterKey ? page.size : PAGE_SIZE; + const sentCursors = page.key === filterKey ? page.cursors : null; + const sentRegrown = page.key === filterKey ? page.regrown : []; + + if (page.key !== filterKey) { + setPage({ key: filterKey, size: PAGE_SIZE, cursors: null, regrown: [] }); + } + + const listTargets = environmentQueries.flatMap(({ environmentId, projectIds }) => { + const cursors = sentCursors?.[environmentId]; + if (sentCursors !== null && cursors === undefined && !sentRegrown.includes(environmentId)) { + return []; + } + return [ + { + environmentId, + input: { + state: search.state, + involvement: search.involvement, + limit: pageSize, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + ...(hasFilters ? { filters } : {}), + ...(sentParsed.text ? { query: sentParsed.text } : {}), + ...(cursors === undefined ? {} : { cursors }), + } satisfies PullRequestListInput, + }, + ]; + }); + const listQuery = usePullRequestList(listTargets); + + /** + * Kept as its own read rather than remembered from an earlier answer because an answer cannot + * say which question it belongs to: mid-switch the text has already changed and the data has + * not, and a search's answer would file itself under the workspace. + */ + const baselineTargets = environmentQueries.map(({ environmentId, projectIds }) => ({ + environmentId, + input: { + state: search.state, + involvement: search.involvement, + limit: PAGE_SIZE, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + ...(menuFiltered ? { filters: menuFilters } : {}), + } satisfies PullRequestListInput, + })); + const baselineQuery = usePullRequestList(baselineTargets); + const baselineEmpty = + baselineQuery.data?.entries.length === 0 && + baselineQuery.data.errors.length === 0 && + !baselineQuery.isPending && + baselineQuery.error === null; + const facetTargets = (() => { + if (!filtersOpen) return NO_LIST_TARGETS; + return environmentQueries.map(({ environmentId, projectIds }) => ({ + environmentId, + input: { + state: "all", + involvement: search.involvement, + limit: PAGE_SIZE, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + } satisfies PullRequestListInput, + })); + })(); + const facetQuery = usePullRequestList(facetTargets); + const partitionsWanted = search.involvement === "all" && typedQuery.length === 0; + const partitionTargets = (() => { + // The main list goes first. Besides putting the visible rows on screen sooner, it proves + // which repositories the host search indexes, so an empty partition does not trigger the + // expensive per-repository fallback. + if ( + !partitionsWanted || + baselineQuery.data === null || + baselineQuery.data.entries.length === 0 + ) { + return { authored: NO_LIST_TARGETS, reviewing: NO_LIST_TARGETS }; + } + const targetsFor = (involvement: PullRequestInvolvement) => + environmentQueries.map(({ environmentId, projectIds }) => ({ + environmentId, + input: { + state: search.state, + involvement, + limit: PAGE_SIZE, + ...(scopedProjectId ? { projectId: scopedProjectId } : {}), + ...(projectIds ? { projectIds } : {}), + ...(search.host ? { host: search.host } : {}), + ...(menuFiltered ? { filters: menuFilters } : {}), + } satisfies PullRequestListInput, + })); + return { authored: targetsFor("authored"), reviewing: targetsFor("reviewing") }; + })(); + const authoredQuery = usePullRequestList(partitionTargets.authored); + const reviewingQuery = usePullRequestList(partitionTargets.reviewing); + const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [detailRefreshToken, setDetailRefreshToken] = useState(0); + const [invalidating, setInvalidating] = useState(false); + const refreshing = invalidating || listQuery.isPending; + + const [loaded, setLoaded] = useState<{ + environmentKey: string; + scope: string; + query: string; + /** The answer this was built from, so a re-render with the same answer leaves it alone. */ + source: MergedPullRequestList | null; + data: MergedPullRequestList; + partitions?: PullRequestPartitionsSnapshot; + } | null>(null); + const [ordered, setOrdered] = useState<{ + key: string; + source: MergedPullRequestList; + entries: ReadonlyArray; + } | null>(null); + const [overrides, setOverrides] = useState>( + () => new Map(), + ); + const overrideToken = useRef(0); + const overrideEntry = ( + entry: EnvironmentPullRequestEntry, + action: PullRequestAction, + ): number | null => { + const token = ++overrideToken.current; + const override = pullRequestOverrideAfterAction(entry, action, new Date(), token); + if (override === null) return null; + setOverrides((current) => new Map(current).set(pullRequestEntryKey(entry), override)); + return token; + }; + const revertOverride = (key: string, token: number | null) => { + if (token === null) return; + setOverrides((current) => { + if (current.get(key)?.token !== token) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + }; + const detailOverrideTokens = useRef(new Map()); + const onSpeedAction = useStableCallback(({ entry, action }: PullRequestSpeedActionResult) => { + // Some hosts accept a merge before it completes. Let the next host read declare it merged. + if (action !== "merge") overrideEntry(entry, action); + }); + const onBatchClosed = useStableCallback((entry: EnvironmentPullRequestEntry) => { + onSpeedAction({ entry, action: "close" }); + }); + const { close: closeBatch, closingKeys } = usePullRequestCloseBatch(onBatchClosed); + const [snapshotEnvironmentKey, setSnapshotEnvironmentKey] = useState(""); + if (environmentKey.length > 0 && snapshotEnvironmentKey !== environmentKey) { + setSnapshotEnvironmentKey(environmentKey); + if (loaded?.environmentKey !== environmentKey) { + const snapshot = readPullRequestListSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentKey, + ); + setLoaded( + snapshot === null + ? null + : { + environmentKey, + scope: snapshot.scope, + query: "", + source: null, + data: snapshot.data, + ...(snapshot.partitions === undefined ? {} : { partitions: snapshot.partitions }), + }, + ); + } + } + const settledList = listQuery.data !== null && !listQuery.isPending ? listQuery.data : null; + const answeredPartitions = + partitionsWanted && baselineEmpty + ? EMPTY_PARTITIONS + : partitionsWanted && authoredQuery.data !== null && reviewingQuery.data !== null + ? { authored: authoredQuery.data.entries, reviewing: reviewingQuery.data.entries } + : null; + const heldPartitions = + loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey + ? loaded.partitions + : undefined; + const nextPartitions = answeredPartitions ?? heldPartitions; + const loadedEntries = + settledList === null + ? null + : ordered?.key === filterKey + ? ordered.entries + : settledList.entries; + if ( + settledList !== null && + loadedEntries !== null && + (loaded === null || + loaded.source !== settledList || + loaded.environmentKey !== environmentKey || + loaded.scope !== scopeKey || + loaded.query !== sentQuery || + loaded.data.entries !== loadedEntries || + loaded.partitions?.authored !== nextPartitions?.authored || + loaded.partitions?.reviewing !== nextPartitions?.reviewing) + ) { + setLoaded({ + environmentKey, + scope: scopeKey, + query: sentQuery, + source: settledList, + data: { ...settledList, entries: loadedEntries }, + ...(nextPartitions === undefined ? {} : { partitions: nextPartitions }), + }); + } + const snapshotToWrite = + loaded !== null && + loaded.source !== null && + loaded.environmentKey.length > 0 && + loaded.query.length === 0 + ? loaded + : null; + const snapshotViewers = baselineQuery.data?.viewers; + const snapshotProviders = baselineQuery.data?.providers; + useEffect(() => { + if (snapshotToWrite === null) return; + writePullRequestListSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + snapshotToWrite.environmentKey, + { + scope: snapshotToWrite.scope, + data: { + ...snapshotToWrite.data, + viewers: snapshotViewers ?? snapshotToWrite.data.viewers, + providers: snapshotProviders ?? snapshotToWrite.data.providers, + }, + ...(snapshotToWrite.partitions === undefined + ? {} + : { partitions: snapshotToWrite.partitions }), + }, + ); + }, [snapshotToWrite, snapshotViewers, snapshotProviders]); + const narrowed = (() => { + if (loaded === null || loaded.environmentKey !== environmentKey || loaded.scope === scopeKey) { + return null; + } + const entries = narrowPullRequestsToFilters(loaded.data.entries, { + state: search.state, + projectId: scopedProjectId, + host: search.host, + }); + return entries.length === 0 ? null : { ...loaded.data, entries }; + })(); + // A grown page is read by the list and nothing else: the baseline always asks for one page, so + // a host with no cursor to continue from — where "more" means asking for a longer page — would + // have its extra rows thrown away for the ninety-nine the baseline keeps answering with. + const answered = + (sentQuery.length === 0 && sentCursors === null && pageSize === PAGE_SIZE + ? baselineQuery.data + : listQuery.data) ?? + (loaded?.scope === scopeKey && loaded.query === sentQuery ? loaded.data : null); + const carried = + (sentQuery.length === 0 ? baselineQuery.data : undefined) ?? + (loaded?.scope === scopeKey ? loaded.data : null) ?? + narrowed; + const listData = answered ?? carried; + const showingCarried = answered === null && carried !== null; + const loadingMore = listQuery.isPending && listData !== null; + const firstLoad = listQuery.isPending && listData === null; + + if ( + answered !== null && + !listQuery.isPending && + !(listQuery.error && listQuery.data === null) && + (ordered === null || ordered.key !== filterKey || ordered.source !== answered) + ) { + if (ordered === null || ordered.key !== filterKey) { + setOrdered({ + key: filterKey, + source: answered, + entries: rankPullRequestMatches(answered.entries, sentParsed.text), + }); + } else if (sentCursors !== null) { + const held = new Set(ordered.entries.map(pullRequestEntryKey)); + const arrived = answered.entries.filter((entry) => !held.has(pullRequestEntryKey(entry))); + const appended = rankPullRequestMatches( + arrived.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), + sentParsed.text, + ); + setOrdered({ key: filterKey, source: answered, entries: [...ordered.entries, ...appended] }); + } else { + setOrdered({ + key: filterKey, + source: answered, + entries: reusePullRequestEntries( + ordered.entries, + rankPullRequestMatches(answered.entries, sentParsed.text), + pullRequestEntryKey, + ), + }); + } + const answeredEntries = answered.entries; + setOverrides((current) => + settlePullRequestOverrides(current, answeredEntries, pullRequestEntryKey, Date.now()), + ); + } + + // From this question's own answer, never from the rows being held while it travels: a + // continuation is a boundary in one listing, and carrying one across a search would ask the new + // question to start where the old one stopped — skipping its newest matches entirely. + const nextCursors = answered?.nextCursors ?? {}; + const regrown = (answered?.truncatedEnvironments ?? []).filter( + (environmentId) => nextCursors[environmentId] === undefined, + ); + const canContinue = !showingCarried && Object.keys(nextCursors).length > 0; + const loadMore = () => { + if (canContinue) { + setPage({ + key: filterKey, + size: regrown.length === 0 ? pageSize : Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE), + cursors: nextCursors, + regrown, + }); + return; + } + setPage({ + key: filterKey, + size: Math.min(pageSize + PAGE_SIZE, MAX_PAGE_SIZE), + cursors: null, + regrown: [], + }); + }; + + const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => { + const related = ( + includeRelated + ? [ + ...baselineTargets, + ...facetTargets, + ...partitionTargets.authored, + ...partitionTargets.reviewing, + ] + : [] + ).filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ); + if (sentCursors === null) { + listQuery.refresh([ + ...listTargets.filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ), + ...related, + ]); + return; + } + if (related.length > 0) listQuery.refresh(related); + const loadedCount = + ordered?.key === filterKey ? largestPullRequestReadCount(ordered.entries) : pageSize; + setPage({ + key: filterKey, + regrown: [], + size: Math.min( + Math.max(pageSize, Math.ceil(loadedCount / PAGE_SIZE) * PAGE_SIZE), + MAX_PAGE_SIZE, + ), + cursors: null, + }); + }; + + const appliedTurnRefreshToken = useRef(""); + const refreshAfterTurn = useEffectEvent(() => { + if (sentCursors !== null) refreshList(); + }); + useEffect(() => { + if (turnRefreshToken.length === 0 || appliedTurnRefreshToken.current === turnRefreshToken) { + return; + } + appliedTurnRefreshToken.current = turnRefreshToken; + refreshAfterTurn(); + }, [turnRefreshToken]); + + useLiveRefresh( + () => { + refreshList(true); + }, + { enabled: pullRequestsSupported }, + ); + + const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; + const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? []; + const facets = collectPullRequestListFacets( + [ + ...(facetQuery.data?.entries ?? []), + ...(baselineQuery.data?.entries ?? listData?.entries ?? []), + ], + { + state: search.state, + involvement: search.involvement, + filters, + }, + viewers, + ); + + const scrollRef = useRef(null); + const { + entries, + listedPullRequestTabStatuses, + groups, + entriesByStatsKey, + heldPullRequestsBySurface, + } = pullRequestGroups({ + known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + providers: baselineQuery.data?.providers ?? listData?.providers ?? [], + viewers, + involvement: search.involvement, + localFilters, + hasLocalFilters, + text: typedParsed.text, + answeredLocally: querySettled && !showingCarried, + baselineEmpty, + held: + loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey + ? loaded.partitions + : undefined, + authored: partitionsWanted ? authoredQuery.data?.entries : undefined, + reviewing: partitionsWanted ? reviewingQuery.data?.entries : undefined, + partitionsWanted, + }); + + const visibleStatsKeys = useRef({ key: filterKey, values: new Set() }); + const [statsByRow, setStatsByRow] = useState(() => new Map()); + const [statsTargetState, setStatsTargetState] = useState<{ + readonly key: string; + readonly batches: ReadonlyArray; + }>({ key: filterKey, batches: [] }); + const statsBatches = statsTargetState.key === filterKey ? statsTargetState.batches : []; + const statsTargets = statsBatches.map(({ environmentId, input }) => ({ + environmentId, + input, + })); + const statsQuery = usePullRequestListStats(statsTargets); + const statsObserver = useRef(null); + const statsRows = useRef(new Set()); + // Called from a row's ref cleanup, which keeps the render it attached in; this reads the latest. + const releaseStatsRow = useStableCallback((key: string | undefined) => { + const visible = visibleStatsKeys.current; + if ( + statsPolicy !== "visible" || + key === undefined || + !visible.values.delete(key) || + statsQuery.isPending + ) { + return; + } + setStatsTargetState((current) => { + if (current.key !== visible.key) return current; + const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values); + return batches.length === current.batches.length ? current : { key: current.key, batches }; + }); + }); + // Every row takes this as its ref, so a new identity would detach and reattach them all. + const registerStatsRow = useStableCallback((node: HTMLDivElement | null) => { + if (node === null || typeof IntersectionObserver === "undefined") return; + statsRows.current.add(node); + statsObserver.current?.observe(node); + return () => { + statsRows.current.delete(node); + statsObserver.current?.unobserve(node); + releaseStatsRow(node.dataset.pullRequestStatsKey); + }; + }); + // Arriving stats must not re-run the effects that ask for them. + const readStatsProgress = useEffectEvent(() => ({ + statsByRow, + pending: statsQuery.isPending, + })); + useEffect(() => { + if (statsPolicy !== "eager") return; + const { statsByRow: known } = readStatsProgress(); + const candidateKeys = visibleStatsKeys.current.values; + setStatsTargetState((current) => { + const batches = current.key === filterKey ? current.batches : []; + const added = pullRequestStatsRequestBatches({ + entriesByKey: entriesByStatsKey, + candidateKeys, + policy: statsPolicy, + activeBatches: batches, + statsByRow: known, + }); + if (added.length === 0 && current.key === filterKey) return current; + return { key: filterKey, batches: [...batches, ...added] }; + }); + }, [filterKey, entriesByStatsKey, statsPolicy]); + // The observer outlives the rows it watches, so it reads the rows held at the time it fires. + const readStatsRows = useEffectEvent(() => entriesByStatsKey); + useEffect(() => { + if (statsPolicy !== "visible" || typeof IntersectionObserver === "undefined") return; + visibleStatsKeys.current = { key: filterKey, values: new Set() }; + const observer = new IntersectionObserver( + (observed) => { + const visible = visibleStatsKeys.current; + if (visible.key !== filterKey) return; + let changed = false; + const entered = new Set(); + const entriesByKey = readStatsRows(); + for (const item of observed) { + const key = (item.target as HTMLElement).dataset.pullRequestStatsKey; + if (key === undefined) continue; + const wasVisible = visible.values.has(key); + if (item.isIntersecting && entriesByKey.has(key)) { + visible.values.add(key); + if (!wasVisible) entered.add(key); + } else { + visible.values.delete(key); + } + if (wasVisible !== visible.values.has(key)) changed = true; + } + if (!changed) return; + const progress = readStatsProgress(); + setStatsTargetState((current) => { + const batches = current.key === filterKey ? current.batches : []; + const retained = progress.pending + ? batches + : retainVisiblePullRequestStatsBatches(batches, visible.values); + const added = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: entered, + policy: statsPolicy, + activeBatches: batches, + statsByRow: progress.statsByRow, + }); + if (added.length === 0 && retained.length === batches.length) return current; + return { key: filterKey, batches: [...retained, ...added] }; + }); + }, + { root: scrollRef.current, rootMargin: "480px" }, + ); + statsObserver.current = observer; + for (const row of statsRows.current) observer.observe(row); + return () => { + observer.disconnect(); + if (statsObserver.current === observer) statsObserver.current = null; + }; + }, [filterKey, statsPolicy]); + useEffect(() => { + if (statsPolicy !== "visible" || statsQuery.isPending) return; + const visible = visibleStatsKeys.current; + setStatsTargetState((current) => { + if (current.key !== filterKey || visible.key !== filterKey) return current; + const batches = retainVisiblePullRequestStatsBatches(current.batches, visible.values); + return batches.length === current.batches.length ? current : { key: current.key, batches }; + }); + }, [filterKey, statsPolicy, statsQuery.isPending]); + const [mergedStats, setMergedStats] = useState(null); + if (statsQuery.stats !== null && statsQuery.stats !== mergedStats) { + const stats = statsQuery.stats; + setMergedStats(stats); + setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); + } + // Stable so a refresh that resumes after awaiting the host compares against the scope on screen + // by then, not the one it started from. + const refreshListAndStats = useStableCallback( + ( + requestedStatsScope: PullRequestStatsScope = statsScope, + actedEnvironmentId?: EnvironmentId, + ) => { + refreshList(true, actedEnvironmentId); + const visible = visibleStatsKeys.current; + const batches = pullRequestStatsRefreshBatches({ + requestedScope: requestedStatsScope, + currentScope: statsScope, + entriesByKey: entriesByStatsKey, + candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(), + statsByRow, + }); + if (batches !== null) { + setStatsTargetState({ key: requestedStatsScope.key, batches }); + statsQuery.refresh( + batches + .filter(({ environmentId }) => + actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId, + ) + .map(({ environmentId, input }) => ({ environmentId, input })), + ); + } + }, + ); + const refreshFromHost = async () => { + const requestedStatsScope = statsScope; + setInvalidating(true); + // Atom commands settle into results rather than throwing. + await Promise.all( + queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })), + ); + setInvalidating(false); + refreshListAndStats(requestedStatsScope); + setDetailRefreshToken((token) => token + 1); + }; + const { + hosts, + displayGroups, + shownCount, + hostActionsOf, + closeSweepRows, + listedPullRequestsBySurface, + } = usePullRequestDisplayGroups({ + answered, + host: search.host, + groups, + statsByRow, + overrides, + state: search.state, + localFilters, + hasLocalFilters, + viewers, + sort, + text: typedParsed.text, + involvement: search.involvement, + }); + const showProvider = hosts.length > 1; + const [closeSweepKeys, setCloseSweepKeys] = useState>(() => new Set()); + const closeSensorRef = useRef(null); + // A sweep spans many renders; each step reads the rows on screen at that moment. + const readCloseSweep = useStableCallback(() => ({ + displayGroups, + closeSweepRows, + closingKeys, + closeBatch, + hostActionsOf, + })); + useEffect( + () => () => closeSensorRef.current?.cancel(), + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- The pointer sensor lives outside React; a new list under a sweep in progress must end it. + [filterKey, search.q, sort], + ); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || closeSensorRef.current === null) return; + event.preventDefault(); + closeSensorRef.current.cancel(); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, []); + const startCloseSweep = useStableCallback( + (entry: EnvironmentPullRequestEntry, event: PointerEvent) => { + closeSensorRef.current?.cancel(); + const originKey = pullRequestEntryKey(entry); + const group = readCloseSweep().displayGroups.find((candidate) => + candidate.entries.some((row) => pullRequestEntryKey(row) === originKey), + ); + if (!group || readCloseSweep().closingKeys.has(originKey)) return; + const orderedKeys = readCloseSweep().displayGroups.flatMap((candidate) => + candidate.entries.map(pullRequestEntryKey), + ); + const canClose = (key: string) => { + const row = readCloseSweep().closeSweepRows.get(key); + return ( + row?.groupKey === group.key && + row.entry.state === "open" && + readCloseSweep().hostActionsOf(row.entry).has("close") && + !readCloseSweep().closingKeys.has(key) && + !scrollRef.current?.querySelector( + `[data-pull-request-key="${CSS.escape(key)}"] [data-pull-request-action-pending="true"]`, + ) + ); + }; + let sweptKeys: string[] = []; + let targetKey: string | null = null; + const sweepTo = (key: string) => { + if (key === targetKey) return; + targetKey = key; + sweptKeys = resolveSidebarSweepKeys(orderedKeys, originKey, key, canClose); + setCloseSweepKeys(new Set(sweptKeys)); + }; + closeSensorRef.current = new SidebarPointerSensor({ + active: originKey, + event, + options: { + distance: 6, + onAttach: () => {}, + onFinish: () => { + closeSensorRef.current = null; + setCloseSweepKeys(new Set()); + }, + }, + onPending: () => {}, + onStart: () => sweepTo(originKey), + onMove: ({ y }) => { + const viewport = scrollRef.current; + if (!viewport) return; + const bounds = viewport.getBoundingClientRect(); + const visibleY = Math.min(Math.max(y, bounds.top), bounds.bottom - 1); + let key: string | null = null; + for (const row of viewport.querySelectorAll("[data-pull-request-key]")) { + if (key !== null && row.getBoundingClientRect().top > visibleY) break; + key = row.dataset.pullRequestKey ?? null; + } + if (key !== null) sweepTo(key); + }, + onEnd: () => { + const batch = sweptKeys.filter(canClose).flatMap((key) => { + const row = readCloseSweep().closeSweepRows.get(key); + return row ? [row.entry] : []; + }); + void readCloseSweep().closeBatch(batch); + }, + onCancel: () => {}, + onAbort: () => {}, + }); + }, + ); + const rightPanelAvailable = selectedPullRequestSurface !== null; + const [maximized, setMaximized] = useState(false); + const rightPanelMaximized = rightPanelState.isOpen && maximized; + if (!rightPanelState.isOpen && maximized) setMaximized(false); + const toggleRightPanelMaximized = () => { + if (rightPanelState.isOpen) setMaximized((value) => !value); + }; + const onGuideShown = () => setMaximized(true); + useEffect(() => { + if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); + }, [linkedSelection, pullRequestsSupported, rightPanelRef]); + + const selected = + rightPanelState.isOpen && activePullRequestSurface !== null + ? { + environmentId: activePullRequestSurface.environmentId, + repository: activePullRequestSurface.repository, + number: activePullRequestSurface.number, + projectId: activePullRequestSurface.projectId as ProjectId, + host: + activePullRequestSurface.host ?? + (selectedProject?.repositoryIdentity + ? pullRequestHostOf( + selectedProject.repositoryIdentity, + selectedProject.repositoryIdentity.provider as SourceControlProviderKind, + ) + : undefined), + } + : null; + + const selectSurfaceInUrl = (surface: PullRequestSurface | null) => + updateSearch( + surface === null + ? clearedSelection + : { + repository: surface.repository, + number: surface.number, + selectedProjectId: surface.projectId as ProjectId, + selectedHost: surface.host, + ...(surface.environmentId === undefined + ? {} + : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), + }, + ); + + const toggleRightPanel = () => { + if (rightPanelRef === null) return; + if (rightPanelState.isOpen) { + useRightPanelStore.getState().close(rightPanelRef); + updateSearch(clearedSelection); + return; + } + if (selectedPullRequestSurface === null) return; + useRightPanelStore.getState().show(rightPanelRef); + selectSurfaceInUrl(selectedPullRequestSurface); + }; + + const unavailableProjects = new Map( + listErrors.map( + (error) => + [ + pullRequestProjectKey({ id: error.projectId, environmentId: error.environmentId }), + error.message, + ] as const, + ), + ); + + const selectEntry = (entry: PullRequestRowTarget) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); + updateSearch({ + repository: entry.repository, + number: entry.number, + selectedProjectId: entry.projectId, + selectedEnvironmentId: entry.environmentId, + selectedHost: entry.host, + }); + }; + + const searchInput = ( + 0 && (!querySettled || showingCarried)} + onChange={(query) => updateListScope({ q: query || undefined })} + /> + ); + const panelToggleControls = ( + undefined} + rightPanelAvailable={rightPanelAvailable} + rightPanelOpen={rightPanelState.isOpen} + rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} + rightPanelUnavailableLabel="Select a pull request first" + onToggleTerminal={() => undefined} + onToggleRightPanel={toggleRightPanel} + /> + ); + const openPanelControls = ( +
+ {rightPanelState.isOpen ? ( + + ) : null} + {panelToggleControls} +
+ ); + const carriedToNothing = + shownCount === 0 && + typedQuery.length === 0 && + (!querySettled || (showingCarried && listQuery.isPending)); + const listBody = ( + <> + {!capabilityKnown ? ( + + ) : !pullRequestsSupported ? ( + + ) : firstLoad ? ( + + ) : listQuery.error && shownCount === 0 ? ( + listQuery.refresh()} + /> + ) : carriedToNothing ? ( + + ) : shownCount === 0 ? ( + 0} + refreshing={refreshing} + onRefresh={() => void refreshFromHost()} + query={typedQuery} + filtered={ + menuFiltered || + search.state !== "open" || + search.involvement !== "all" || + scopedProjectId !== undefined || + search.host !== undefined + } + searching={typedQuery.length > 0 && (!querySettled || showingCarried)} + canLoadMore={listData?.truncated === true && (canContinue || pageSize < MAX_PAGE_SIZE)} + loadingMore={loadingMore} + onClearQuery={() => updateListScope({ q: undefined })} + onLoadMore={loadMore} + /> + ) : ( + 1 ? environmentLabels : null} + query={typedParsed.text} + selected={selected} + showProvider={showProvider} + statsRef={registerStatsRow} + onSelect={selectEntry} + hostActionsOf={hostActionsOf} + onActed={onSpeedAction} + closingKeys={closingKeys} + sweepingKeys={closeSweepKeys} + onCloseSweepStart={startCloseSweep} + /> + )} + + {listQuery.error && shownCount > 0 ? ( +
+ {listQuery.error} Showing the last pull requests loaded. + +
+ ) : null} + {listData?.truncated && entries.length > 0 ? ( +
+ {loadingMore ? ( + + + {sentCursors === null ? "Updating pull requests" : "Loading more"} + + ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( + + ) : ( + Narrow your search to find more pull requests. + )} +
+ ) : null} + + ); + const hostEntries = hosts.length > 0 ? hosts : expectedHosts; + const hostMenuOptions: ReadonlyArray> = [ + { value: "", label: "All", Icon: Plug2Icon }, + ...hostEntries.map((entry) => { + const summary = hosts.find((host) => host.host === entry.host); + return { + value: entry.host, + label: pullRequestHostLabel(hostEntries, entry), + Icon: getSourceControlPresentationForKind(entry.kind).Icon, + ...(summary === undefined || summary.configured + ? {} + : { unavailable: summary.detail ?? "This host could not be read." }), + }; + }), + ]; + const serverMenuOptions: ReadonlyArray> = [ + { value: "", label: "All servers", Icon: LayersIcon }, + ...capableEnvironments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + Icon: environmentMachineIcon(resolveEnvironmentMachineKind(environment.serverConfig)), + })), + ]; + const sortMenu = ( + } + triggerLabel="Sort" + outlined + value={sort} + options={SORT_OPTIONS} + onChange={(next) => updateListScope({ sort: next })} + /> + ); + const filtersMenu = ( + updateListScope({ state })} + involvement={search.involvement} + involvementOptions={INVOLVEMENT_TABS} + onInvolvement={(involvement) => updateListScope({ involvement })} + filters={ + typedParsed.filters.draft + ? { ...menuFilters, draft: typedParsed.filters.draft } + : menuFilters + } + onFilters={(value) => { + const { query, filters: next } = applyPullRequestDraftFilterChange( + typedQuery, + menuFilters, + value, + ); + updateListScope({ + ...(query === typedQuery ? {} : { q: query || undefined }), + draft: next.draft, + review: next.review, + checks: next.checks, + author: next.author, + labels: next.labels?.flatMap((group) => group), + }); + }} + facets={facets} + host={search.host} + hostOptions={hostMenuOptions} + onHost={(host) => updateListScope({ host })} + server={scopedEnvironmentId ?? undefined} + serverOptions={serverMenuOptions} + onServer={(server) => updateListScope({ environmentId: server, projectId: undefined })} + projects={scopedProjects} + projectId={scopedProjectId} + projectEnvironmentId={scopedProject?.environmentId} + unavailable={unavailableProjects} + onProject={(projectId, environmentId) => + updateListScope(environmentId === undefined ? { projectId } : { projectId, environmentId }) + } + /> + ); + const columnProps = { + refreshing, + onRefresh: () => void refreshFromHost(), + searchValue: search.q ?? "", + involvement: search.involvement, + state: search.state, + host: search.host, + hostMenuOptions, + onInvolvement: (involvement: PullRequestInvolvement) => updateListScope({ involvement }), + onState: (state: PullRequestListState) => updateListScope({ state }), + onHost: (host: string | undefined) => updateListScope({ host }), + searchInput, + sortMenu, + filtersMenu, + rightPanelControl: !pullRequestsSupported ? null : ( + + ), + titlebarControls: + // While the panel is closed the strip lives inside the header: a no-drag + // descendant beats the header's desktop drag-region, where a floating + // sibling loses (app-region hit-testing ignores z-index). + pullRequestsSupported ? ( + rightPanelPresent ? ( + + ) : ( + openPanelControls + ) + ) : null, + rightPanelOpen: rightPanelState.isOpen, + maximizedAway: rightPanelMaximized, + listBody, + scrollRef, + }; + + const activateSurface = (surface: PullRequestSurface) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().activateSurface(rightPanelRef, surface.id); + selectSurfaceInUrl(surface); + }; + const closeSurface = (surface: PullRequestSurface) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().closeSurface(rightPanelRef, surface.id); + const next = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + rightPanelRef, + ); + selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); + }; + const closeOtherSurfaces = (surface: PullRequestSurface) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().closeOtherSurfaces(rightPanelRef, surface.id); + selectSurfaceInUrl(surface); + }; + const closeSurfacesToRight = (surface: PullRequestSurface) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().closeSurfacesToRight(rightPanelRef, surface.id); + const next = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + rightPanelRef, + ); + selectSurfaceInUrl(next?.kind === "pull-request" ? next : null); + }; + const closeAllSurfaces = () => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().closeAllSurfaces(rightPanelRef); + selectSurfaceInUrl(null); + }; + + const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!openPanelPullRequestUrl) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const url = openPanelPullRequestUrl; + void writeTextToClipboard(url, "pull request link").then( + (didCopy) => { + if (didCopy) + toastManager.add({ type: "success", title: "PR link copied", description: url }); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }); + const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (activePullRequestSurface === null) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) closeSurface(activePullRequestSurface); + }); + const toggleRightPanelFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!rightPanelAvailable) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) toggleRightPanel(); + }); + const toggleRightPanelMaximizedFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!rightPanelState.isOpen) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) toggleRightPanelMaximized(); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: getShortcutContext(), + }); + if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); + if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); + if (command === "rightPanel.toggleMaximized") toggleRightPanelMaximizedFromShortcut(event); + if (command === "thread.copyReference") copyPullRequestFromShortcut(event); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [keybindings]); + + return ( + +
+ {pullRequestsSupported && rightPanelPresent ? openPanelControls : null} + + + {rightPanelPresent && renderedPullRequestSurface && panelEnvironmentId !== null ? ( + { + if (surface.kind === "pull-request") activateSurface(surface); + }} + onCloseSurface={(surface) => { + if (surface.kind === "pull-request") closeSurface(surface); + }} + onCloseOtherSurfaces={(surface) => { + if (surface.kind === "pull-request") closeOtherSurfaces(surface); + }} + onCloseSurfacesToRight={(surface) => { + if (surface.kind === "pull-request") closeSurfacesToRight(surface); + }} + onCloseAllSurfaces={closeAllSurfaces} + onMoveSurface={(surfaceId, toIndex) => { + if (rightPanelRef !== null) + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); + }} + onCopyFilePath={() => undefined} + onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} + onAddTerminal={() => undefined} + onAddDiff={() => undefined} + onAddFiles={() => undefined} + onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} + onAddAgents={() => undefined} + onAddDevice={() => undefined} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + agentsAvailable={false} + liveAgentCount={0} + deviceAvailable={false} + pullRequestStatusSeeds={listedPullRequestTabStatuses} + > + { + if (rightPanelRef === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + environmentId: panelEnvironmentId, + }); + updateSearch({ + repository: reference.repository, + number: reference.number, + selectedHost: reference.host, + selectedProjectId: reference.projectId, + selectedEnvironmentId: panelEnvironmentId, + }); + }} + reference={{ + projectId: renderedPullRequestSurface.projectId as ProjectId, + repository: renderedPullRequestSurface.repository, + number: renderedPullRequestSurface.number, + ...(renderedPullRequestSurface.host + ? { host: renderedPullRequestSurface.host } + : {}), + }} + listEntry={ + listedPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ) ?? null + } + refreshToken={detailRefreshToken} + onActed={(action, phase = "done") => { + // From every row held, not the ones on screen: a pull request closed from an open + // list has left the screen, and reopening it has to find it anyway. + const acted = heldPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ); + const stateOnly = + action !== undefined && + acted !== undefined && + pullRequestOverrideAfterAction(acted, action, new Date(), 0) !== null; + if (stateOnly) { + const key = pullRequestEntryKey(acted); + if (phase === "sent" && action !== "merge") { + detailOverrideTokens.current.set(key, overrideEntry(acted, action)); + } + if (phase === "failed" && action !== "merge") { + revertOverride(key, detailOverrideTokens.current.get(key) ?? null); + } + if (phase !== "sent") detailOverrideTokens.current.delete(key); + if (phase === "done" && action === "merge") { + overrideEntry(acted, action); + refreshListAndStats(undefined, panelEnvironmentId); + } + return; + } + if (phase === "done") refreshListAndStats(undefined, panelEnvironmentId); + }} + /> + + ) : null} +
+
+ ); +} + +function PullRequestFiltersMenuInSidebarOrder( + props: ComponentProps, +) { + const sidebarProjectGroups = useSidebarProjectGroups(); + const projects = sortProjectsByGroupOrder(props.projects, sidebarProjectGroups); + return ; +} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 07b6626564a9..73317758e6c9 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -34,6 +34,7 @@ import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullReques import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "~/shortcutModifierState"; import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities"; +import { PullRequestChangeBreakdownStat } from "./PullRequestChangeBreakdownStat"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -58,7 +59,7 @@ import { } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; import { pullRequestQuickActions } from "./pullRequestList.logic"; -import { PullRequestSpeedActions } from "./PullRequestSpeedActions"; +import { PullRequestSpeedActions, PullRequestSpeedModeContext } from "./PullRequestSpeedActions"; const SOURCE_LABELS: Record = { manual: "Linked by you", @@ -245,11 +246,26 @@ function LinkRowImpl({ ) : null } status={ - + projectId === null ? ( + + ) : ( + + ) } meta={ <> @@ -298,11 +314,7 @@ function LinkRowImpl({ />
{actionEntry !== null ? ( - + ) : null} - {/* Threads that run a stack or a sweep link hundreds of pull requests; only the rows in + +
+ {/* Threads that run a stack or a sweep link hundreds of pull requests; only the rows in view are mounted. */} - - data={lines} - keyExtractor={lineKey} - extraData={rowInputs} - renderItem={({ item: line }) => ( - <> + + data={lines} + keyExtractor={lineKey} + extraData={rowInputs} + renderItem={({ item: line }) => ( - {supportsWatchPause ? : null} - - )} - estimatedItemSize={44} - drawDistance={440} - className="min-h-0 flex-1 overscroll-contain p-1.5" - /> -
- - {openCount} open · {linkCount} linked - {lastSynced ? ( - <> - {" · synced "} - - - ) : null} - - -
-
+ )} + estimatedItemSize={44} + drawDistance={440} + className="min-h-0 flex-1 overscroll-contain p-1.5" + /> +
+ + {openCount} open · {linkCount} linked + {lastSynced ? ( + <> + {" · synced "} + + + ) : null} + + +
+
+ ); } diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx deleted file mode 100644 index c1e3f407a7b8..000000000000 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts"; -import { Children, isValidElement, type ReactNode } from "react"; -import { describe, expect, it } from "vite-plus/test"; - -import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; -import { PullRequestRow } from "./PullRequestRow"; -import { - pullRequestChecksState, - pullRequestCheckStatusLabel, - summarizePullRequestChecks, -} from "./pullRequestPresentation"; - -function check( - status: PullRequestCheck["status"], - overrides: Partial = {}, -): PullRequestCheck { - return { name: `check-${status}`, status, description: null, url: null, ...overrides }; -} - -describe("pullRequestChecksState", () => { - it("lets a failure outrank a run still going, and reports nothing without checks", () => { - expect(pullRequestChecksState([check("success"), check("pending"), check("failure")])).toBe( - "failing", - ); - expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing"); - expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending"); - expect(pullRequestChecksState([check("success"), check("action-required")])).toBe("pending"); - expect(pullRequestChecksState([check("success")])).toBe("passing"); - expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null); - expect(pullRequestChecksState([])).toBe(null); - }); - - it("names workflow approval instead of claiming every check passed", () => { - const workflow = check("action-required", { - url: "https://github.com/acme/web/actions/runs/42/job/7", - }); - const manualGate = check("action-required", { url: "https://example.com/manual-gate" }); - expect(pullRequestCheckStatusLabel(workflow)).toBe("Awaiting approval"); - expect(pullRequestCheckStatusLabel(manualGate)).toBe("Awaiting action"); - expect(summarizePullRequestChecks([check("success"), workflow])).toBe( - "1 workflow awaiting approval", - ); - expect(summarizePullRequestChecks([check("failure"), workflow])).toBe("1 of 2 failing"); - expect(summarizePullRequestChecks([check("success"), manualGate])).toBe( - "1 check awaiting action", - ); - expect(summarizePullRequestChecks([workflow, manualGate])).toBe( - "1 workflow and 1 check awaiting action", - ); - }); -}); - -function flatten(node: ReactNode): ReadonlyArray> { - const found: unknown[] = []; - for (const child of Children.toArray(node)) { - if (!isValidElement(child)) continue; - found.push(child); - for (const value of Object.values(child.props as Record)) { - if (isValidElement(value)) found.push(...flatten(value)); - else if (Array.isArray(value)) found.push(...flatten(value.filter(isValidElement))); - } - } - return found as ReadonlyArray>; -} - -function entry(overrides: Partial): EnvironmentPullRequestEntry { - return { - environmentId: "env-1" as EnvironmentId, - projectId: "project-1" as ProjectId, - provider: "github", - repository: "pingdotgg/t3code", - number: 1, - title: "Add the pull requests page", - url: "https://github.com/pingdotgg/t3code/pull/1", - author: null, - headBranch: "feat/page", - baseBranch: "main", - state: "open", - isDraft: false, - mergeability: "mergeable", - additions: 1, - deletions: 0, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - viewerReviewRequested: false, - labels: [], - ...overrides, - } as EnvironmentPullRequestEntry; -} - -function row(overrides: Partial): ReactNode { - return PullRequestRow.type({ - entry: entry(overrides), - selected: false, - showProjectTitle: false, - showProvider: false, - onSelect: () => {}, - speedMode: false, - hostActions: new Set(), - onActed: () => {}, - }); -} - -describe("PullRequestRow checks indicator", () => { - function indicators(node: ReactNode): number { - return flatten(node).filter( - (element) => (element as { type?: unknown }).type === PullRequestChecksPopover, - ).length; - } - - it("shows the indicator only for a row the host reported a rollup for", () => { - expect(indicators(row({ checksState: "failing" }))).toBe(1); - expect(indicators(row({}))).toBe(0); - }); -}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 693c5d2a9bd3..83d99c166c63 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -4,7 +4,6 @@ import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLe import { GitCommandError, ProjectId, - PullRequestAction, type PullRequestCheck, type PullRequestComment, type PullRequestDetail, @@ -28,11 +27,8 @@ import { import { buildAddSelectionToAgentHandoff, - buildAskAboutGuideStopHandoff, - classifyPullRequestChecks, - groupPullRequestChecks, - describePullRequestChecks, resolveThreadPanelPullRequestAction, + buildAskAboutGuideStopHandoff, buildAskAboutPullRequestHandoff, buildExplainPullRequestHandoff, buildPullRequestCommentReferenceContext, @@ -65,10 +61,7 @@ import { pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestCheckoutCommand, - pullRequestFindingKey, - pullRequestReviewOutcome, checkoutFailureDescription, - readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestReferenceHost, @@ -76,65 +69,12 @@ import { allowsSinglePullRequestMerge, shouldRefreshPullRequestActivity, resolveBaseFreshness, - resolvePullRequestMergeMethod, buildPullRequestTimeline, - editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import type { ReviewCommentContext } from "~/reviewCommentContext"; -it("groups checks needing attention before running and completed checks without losing any", () => { - const checks = ( - [ - "success", - "pending", - "failure", - "skipped", - "action-required", - "cancelled", - "neutral", - "pending", - ] as const - ).map((status, index) => ({ name: `check-${index}`, status, description: null, url: null })); - const grouped = groupPullRequestChecks(checks); - expect(grouped.attention.map((check) => check.name)).toEqual(["check-2", "check-4", "check-5"]); - expect(grouped.running.map((check) => check.name)).toEqual(["check-1", "check-7"]); - expect(grouped.completed.map((check) => check.name)).toEqual(["check-0", "check-3", "check-6"]); - expect(checks[0]?.status).toBe("success"); - expect(groupPullRequestChecks([])).toEqual({ attention: [], running: [], completed: [] }); -}); - describe("pull request checkout commands", () => { - it.each([ - ["github", "feature", null, "gh pr checkout 42"], - ["gitlab", "feature", null, "glab mr checkout 42"], - ["forgejo", "feature", null, null], - ["azure-devops", "feature", null, "az repos pr checkout --id 42"], - [ - "bitbucket", - "feature/checkout", - "maria/t3code", - "git clone --single-branch --branch feature/checkout https://bitbucket.org/maria/t3code.git t3code-pr-42", - ], - ["unknown", "feature", null, null], - ] as const)("builds the %s command", (provider, branch, repository, expected) => { - expect( - pullRequestCheckoutCommand(SourceControlProviderKind.make(provider), 42, branch, repository), - ).toBe(expected); - }); - it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => { - expect( - pullRequestCheckoutCommand( - SourceControlProviderKind.make("forgejo"), - 42, - "feature", - null, - "https://forgejo.local:3000/git/maria/repo", - ), - ).toBe( - "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD", - ); - }); it("quotes shell metacharacters in Forgejo repository URLs", () => { expect( pullRequestCheckoutCommand( @@ -165,15 +105,6 @@ describe("pull request checkout commands", () => { provider, }); - it("uses a public host when no repository identity is available", () => { - expect(loadingPullRequestCheckoutCommand(reference("github.com"), undefined)).toBe( - "gh pr checkout 42", - ); - expect(loadingPullRequestCheckoutCommand(reference("gitlab.com"), null)).toBe( - "glab mr checkout 42", - ); - }); - it("uses a matching enterprise identity and rejects an explicit host mismatch", () => { const enterprise = identity("github", "github.example.test/acme/web"); expect(loadingPullRequestCheckoutCommand(reference("github.example.test"), enterprise)).toBe( @@ -182,6 +113,7 @@ describe("pull request checkout commands", () => { expect(loadingPullRequestCheckoutCommand(reference("github.com"), enterprise)).toBeNull(); }); + // regression: #12721 loading layout showed a checkout command guessed from the PR number alone it("does not infer a number-only command without a trusted provider", () => { expect(loadingPullRequestCheckoutCommand(reference(), undefined)).toBeNull(); expect( @@ -218,36 +150,12 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; -describe("pull request merge method", () => { - it("uses the current choice, then the project default, then the last choice", () => { - expect( - resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"), - ).toBe("squash"); - expect( - resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"), - ).toBe("rebase"); - expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe( - "rebase", - ); - expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash"); - }); -}); - describe("pull request activity refresh", () => { const first = { key: "project:acme/web#7", updatedAt: "2026-08-13T13:00:00Z", }; - it("refreshes activity only after the same pull request changes", () => { - expect( - shouldRefreshPullRequestActivity(first, { - ...first, - updatedAt: "2026-08-13T13:01:00Z", - }), - ).toBe(true); - }); - it("does not duplicate the first activity read or carry a revision across pull requests", () => { expect(shouldRefreshPullRequestActivity(null, first)).toBe(false); expect(shouldRefreshPullRequestActivity(first, first)).toBe(false); @@ -278,24 +186,6 @@ describe("review thread comment pages", () => { { id: "c3", body: "next page" }, ]); }); - - it("keeps a loaded comment after its body is edited", () => { - const loaded = [ - { id: "c2", body: "old body" }, - { id: "c3", body: "another loaded comment" }, - ]; - - expect(editPullRequestThreadComment(loaded, "c2", "saved body")).toEqual([ - { id: "c2", body: "saved body" }, - { id: "c3", body: "another loaded comment" }, - ]); - }); -}); - -describe("pull request action menu", () => { - it("keeps the group divider when auto-merge is the only action", () => { - expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true); - }); }); describe("pull request primary control", () => { @@ -311,6 +201,7 @@ describe("pull request primary control", () => { canEnableAutoMerge: true, }; + // regression: #9188 pending/failing checks offered Merge instead of auto-merge it("moves pending and failing checks to auto-merge", () => { expect(resolvePullRequestPrimaryControl({ ...open, checksState: "pending" })).toBe( "enable-auto-merge", @@ -329,47 +220,9 @@ describe("pull request primary control", () => { }), ).toBe("merge"); }); - - it("keeps armed and terminal states in the merge button slot", () => { - expect(resolvePullRequestPrimaryControl({ ...open, autoMergeEnabled: true })).toBe( - "auto-merge-armed", - ); - expect(resolvePullRequestPrimaryControl({ ...open, state: "merged" })).toBe("merged"); - expect(resolvePullRequestPrimaryControl({ ...open, state: "closed" })).toBe("closed"); - }); - - it("keeps conflicts and drafts actionable before merge", () => { - expect(resolvePullRequestPrimaryControl({ ...open, mergeability: "conflicting" })).toBe( - "resolve", - ); - expect(resolvePullRequestPrimaryControl({ ...open, isDraft: true })).toBe("ready"); - }); }); describe("stacked pull request classification", () => { - it("requires a known default branch", () => { - expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false); - }); - - it("recognizes local and remote forms of the default branch", () => { - expect( - isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]), - ).toBe(false); - expect( - isStackedPullRequestBase("main", [ - { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, - ]), - ).toBe(false); - }); - - it("classifies a non-default base as stacked once the default is known", () => { - expect( - isStackedPullRequestBase("feature-base", [ - { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, - ]), - ).toBe(true); - }); - it("does not mistake a nested branch suffix for the default branch", () => { expect( isStackedPullRequestBase("main", [ @@ -458,20 +311,6 @@ describe("grouping the conversation", () => { }); describe("review verdicts", () => { - it("reads the same three verdicts however a host spells them", () => { - expect(pullRequestReviewOutcome("APPROVED")).toBe("approved"); - expect(pullRequestReviewOutcome("approved")).toBe("approved"); - expect(pullRequestReviewOutcome("CHANGES_REQUESTED")).toBe("changes-requested"); - expect(pullRequestReviewOutcome("changes_requested")).toBe("changes-requested"); - expect(pullRequestReviewOutcome("DISMISSED")).toBe("dismissed"); - }); - - it("is not a verdict where the review only carried remarks", () => { - expect(pullRequestReviewOutcome("COMMENTED")).toBeNull(); - expect(pullRequestReviewOutcome("PENDING")).toBeNull(); - expect(pullRequestReviewOutcome(null)).toBeNull(); - }); - it("keeps each reviewer's last word, whatever order the host returned them in", () => { const review = ( id: string, @@ -502,51 +341,6 @@ describe("review verdicts", () => { ]); }); - it("keeps two deleted accounts apart rather than counting them as one reviewer", () => { - expect( - latestPullRequestReviewOutcomes([ - { - ...TIMELINE_SOURCE.comments[0]!, - id: "r1", - kind: "review", - author: null, - reviewState: "APPROVED", - createdAt: "2026-07-01T00:00:00Z", - }, - { - ...TIMELINE_SOURCE.comments[0]!, - id: "r2", - kind: "review", - author: null, - reviewState: "APPROVED", - createdAt: "2026-07-02T00:00:00Z", - }, - ]), - ).toHaveLength(2); - }); - - it("gives every entry a key that separates the reviewers it kept apart", () => { - const entries = latestPullRequestReviewOutcomes([ - { - ...TIMELINE_SOURCE.comments[0]!, - id: "r1", - kind: "review", - author: null, - reviewState: "APPROVED", - createdAt: "2026-07-01T00:00:00Z", - }, - { - ...TIMELINE_SOURCE.comments[0]!, - id: "r2", - kind: "review", - author: null, - reviewState: "APPROVED", - createdAt: "2026-07-01T00:00:00Z", - }, - ]); - expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); - }); - it("calls a verdict stale once commits land after it, and current before that", () => { const commits = [ { oid: "c0ffee", messageHeadline: "later work", committedDate: "2026-07-05T00:00:00Z" }, @@ -569,16 +363,6 @@ describe("review verdicts", () => { ); }); - it("measures staleness against the newest commit, not the last one listed", () => { - expect( - newestPullRequestCommitAt([ - { oid: "a", messageHeadline: "", committedDate: "2026-07-09T00:00:00Z" }, - { oid: "b", messageHeadline: "", committedDate: "2026-07-02T00:00:00Z" }, - ]), - ).toBe("2026-07-09T00:00:00Z"); - expect(newestPullRequestCommitAt([])).toBeNull(); - }); - it("orders instants rather than their text, so a UTC offset cannot invert them", () => { expect( newestPullRequestCommitAt([ @@ -617,64 +401,6 @@ describe("review verdicts", () => { }); describe("pull request timeline", () => { - it("orders creation, commits and comments newest first", () => { - expect(buildPullRequestTimeline(TIMELINE_SOURCE).map((event) => event.id)).toEqual([ - "c1", - "1baf7bdcafe", - "created", - ]); - }); - - it("carries the comment url, and leaves the events the host cannot address without one", () => { - const events = buildPullRequestTimeline({ - ...TIMELINE_SOURCE, - comments: [{ ...TIMELINE_SOURCE.comments[0]!, url: "https://example.test/pull/1#c1" }], - }); - expect(events.map((event) => event.url)).toEqual([ - "https://example.test/pull/1#c1", - null, - null, - ]); - }); - - it("carries actors and review context into the presentation model", () => { - const commitAuthors = [ - { login: "octocat", name: null, avatarUrl: "https://example.test/octocat.png" }, - { login: "pair", name: "Pair Author", avatarUrl: null }, - ]; - const events = buildPullRequestTimeline({ - ...TIMELINE_SOURCE, - commits: [{ ...TIMELINE_SOURCE.commits[0]!, authors: commitAuthors }], - comments: [ - { - ...TIMELINE_SOURCE.comments[0]!, - kind: "review", - path: "src/app.ts", - reviewState: "APPROVED", - }, - ], - }); - - expect(events.find((event) => event.kind === "commit")?.commitAuthors).toEqual(commitAuthors); - expect(events.find((event) => event.kind === "review")).toMatchObject({ - actor: TIMELINE_SOURCE.comments[0]?.author, - path: "src/app.ts", - reviewState: "APPROVED", - }); - }); - - it("carries each commit's line counts into its timeline event", () => { - const events = buildPullRequestTimeline({ - ...TIMELINE_SOURCE, - commits: [{ ...TIMELINE_SOURCE.commits[0]!, additions: 12, deletions: 4 }], - }); - - expect(events.find((event) => event.kind === "commit")).toMatchObject({ - additions: 12, - deletions: 4, - }); - }); - it("drops a body that is nothing but a bot's HTML comment, and keeps one that says more", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, @@ -694,14 +420,6 @@ describe("pull request timeline", () => { ); }); - it("calls a comment markdown and a commit headline plain text", () => { - const events = buildPullRequestTimeline(TIMELINE_SOURCE); - expect(events.map((event) => [event.title.startsWith("Commit"), event.markdown])).toEqual( - expect.arrayContaining([[true, false]]), - ); - expect(events.find((event) => event.id === "c1")?.markdown).toBe(true); - }); - it("reports a merge rather than the close GitHub records alongside it", () => { const events = buildPullRequestTimeline({ ...TIMELINE_SOURCE, @@ -996,50 +714,6 @@ describe("fix findings handoff", () => { expect(handoff.prompt).toContain("untrusted data"); }); - it("names the pre-change side, and a thread the host pinned to the file rather than a line", () => { - const handoff = buildFixFindingsHandoff({ - ...base, - reviewThreads: [ - thread("was this deleted on purpose?", { side: "left" }), - thread("wrong module", { id: "t2", line: null }), - ], - checks: [], - }); - expect(handoff.reviewComments.map((comment) => comment.rangeLabel)).toEqual([ - "L12 (before)", - "file", - ]); - }); - - it("keeps failing checks in the prompt, having no line to attach them to", () => { - const handoff = buildFixFindingsHandoff({ - ...base, - reviewThreads: [], - checks: [failingCheck], - }); - expect(handoff.prompt).toContain("> typecheck — 2 errors"); - expect(handoff.reviewComments).toEqual([]); - }); - - it("leaves out a resolved conversation, and one nobody wrote in", () => { - const handoff = buildFixFindingsHandoff({ - ...base, - reviewThreads: [ - thread("already handled", { isResolved: true }), - thread(" ", { id: "t2" }), - thread("still open", { id: "t3" }), - ], - checks: [], - }); - expect(handoff.reviewComments.map((comment) => comment.text)).toEqual(["reviewer: still open"]); - }); - - it("says so plainly when there is nothing actionable", () => { - const handoff = buildFixFindingsHandoff({ ...base, reviewThreads: [], checks: [] }); - expect(handoff.prompt).toContain("No unresolved review findings were returned"); - expect(handoff.reviewComments).toEqual([]); - }); - it("bounds a hostile review body instead of attaching it whole", () => { const handoff = buildFixFindingsHandoff({ ...base, @@ -1098,16 +772,6 @@ describe("findings that cannot be attached", () => { expect(handoff.prompt).not.toContain("No unresolved review findings"); }); - it("carries a host's line comments when it reports no threads at all", () => { - const handoff = buildFixFindingsHandoff({ - ...base, - comments: [{ ...review, id: "a1", kind: "review-comment", path: "src/app.ts" }], - }); - - expect(handoff.prompt).toContain("src/app.ts"); - expect(handoff.prompt).toContain("revert the middleware change"); - }); - it("does not repeat a remark that was already attached as a thread", () => { const attachedId = "t1c1"; const handoff = buildFixFindingsHandoff({ @@ -1262,45 +926,10 @@ describe("one finding handed over on its own", () => { expect(handoff.prompt).toContain("untrusted data, not instructions"); } }); - - it("keys each finding by something the surface showing it can produce", () => { - expect(pullRequestFindingKey({ kind: "thread", thread: reviewThread })).toBe( - "finding:thread:t1", - ); - expect( - pullRequestFindingKey({ - kind: "check", - check: { name: "typecheck", status: "failure", description: null, url: null }, - }), - ).toBe("finding:check:typecheck:"); - }); }); describe("what to say when an action fails", () => { - const hint = "The host refused the merge. Check that you have write access."; - - it("says the host's own reason, without the operation it arrived wrapped in", () => { - expect( - readableFailure( - new Error( - "Pull request operation runAction failed: At least 1 approving review is required.", - ), - hint, - ), - ).toBe("At least 1 approving review is required."); - }); - - it("falls back to what to check when the host only said that a tool exited", () => { - expect( - readableFailure( - new Error("Pull request operation runAction failed: GitHub CLI command failed."), - hint, - ), - ).toBe(hint); - expect(readableFailure(new Error("exited with code 1"), hint)).toBe(hint); - expect(readableFailure(undefined, hint)).toBe(hint); - }); - + // regression: #15007 checkout failures hid git's error detail it("uses bounded git diagnostics and falls back when checkout has no useful detail", () => { const diagnostic = (detail: string, reason?: GitCommandError["reason"]) => new GitCommandError({ @@ -1322,58 +951,6 @@ describe("what to say when an action fails", () => { ); } }); - - it("bounds a host that answers with a page of output", () => { - const long = readableFailure(new Error("x".repeat(900)), hint); - expect(long.length).toBeLessThanOrEqual(320); - expect(long.endsWith("…")).toBe(true); - }); -}); - -describe("findings that are already on a line", () => { - it("does not quote a resolved thread's comment as a remark with nowhere to hang", () => { - const resolved: PullRequestReviewThread = { - id: "t-resolved", - path: "apps/web/src/page.tsx", - line: 4, - side: "right", - isResolved: true, - isOutdated: false, - comments: [ - { - id: "settled", - author: { login: "reviewer", name: null, avatarUrl: null }, - body: "this was already fixed", - createdAt: "2026-07-02T00:00:00Z", - url: null, - }, - ], - }; - const handoff = buildFixFindingsHandoff({ - number: 42, - title: "Add the pull requests page", - url: "https://github.com/pingdotgg/t3code/pull/42", - headBranch: "feat/page", - baseBranch: "main", - reviewThreads: [resolved], - comments: [ - { - id: "settled", - kind: "review-comment", - author: { login: "reviewer", name: null, avatarUrl: null }, - body: "this was already fixed", - createdAt: "2026-07-02T00:00:00Z", - url: null, - path: "apps/web/src/page.tsx", - reviewState: null, - }, - ], - checks: [], - commentsTruncated: false, - }); - expect(handoff.prompt).not.toContain("this was already fixed"); - expect(handoff.reviewComments).toEqual([]); - }); }); describe("asking about a change rather than working on it", () => { @@ -1648,48 +1225,6 @@ describe("a second ask into the same composer", () => { ).toEqual([own.id, "pull-request-context:42"]); }); - it("empties what the last ask left, so the two are never sent as one question", () => { - const handed = "Explain this pull request."; - expect(handoffPrompt({ prompt: handed, lastHandoffPrompt: handed }, "")).toBe(""); - }); - - it("removes the previous handoff chip before replacing its prompt", () => { - const previous = chip("pull-request-context:42"); - const prompt = `Explain this pull request. ${formatInlineContextReference( - reviewCommentContextReference(previous), - )} `; - expect(stripPullRequestHandoffReferences(prompt, [previous])).toBe( - "Explain this pull request.", - ); - }); - - it("keeps a handoff reference when the next action deliberately repeats it", () => { - const previous = chip("pull-request-context:42"); - const prompt = formatInlineContextReference(reviewCommentContextReference(previous)); - expect(stripPullRequestHandoffReferences(prompt, [previous], new Set([previous.id]))).toBe( - prompt, - ); - }); - - it("replaces the last ask's prompt with this one's", () => { - const handed = "Explain this pull request."; - expect(handoffPrompt({ prompt: handed, lastHandoffPrompt: handed }, "Why the cache key?")).toBe( - "Why the cache key?", - ); - }); - - it("leaves a sentence the reader typed themselves where it is", () => { - expect( - handoffPrompt({ prompt: "check the migration first", lastHandoffPrompt: undefined }, ""), - ).toBe("check the migration first"); - }); - - it("keeps what the reader wrote next to the chip an earlier ask left", () => { - expect( - handoffPrompt({ prompt: "why is the cache keyed on the branch?", lastHandoffPrompt: "" }, ""), - ).toBe("why is the cache keyed on the branch?"); - }); - it("keeps an edit the reader made to the sentence they were handed", () => { expect( handoffPrompt( @@ -1702,15 +1237,6 @@ describe("a second ask into the same composer", () => { ).toBe("Explain this pull request, the caching especially."); }); - it("puts an ask under what the reader typed rather than over it", () => { - expect( - handoffPrompt( - { prompt: "check the migration first", lastHandoffPrompt: undefined }, - "Explain this pull request.", - ), - ).toBe("check the migration first\n\nExplain this pull request."); - }); - it("replaces only its own sentence under text the reader typed", () => { expect( handoffPrompt( @@ -1723,18 +1249,6 @@ describe("a second ask into the same composer", () => { ).toBe("check the migration first\n\nWhy the cache key?"); }); - it("takes back only its own sentence when the next ask is empty", () => { - expect( - handoffPrompt( - { - prompt: "check the migration first\n\nExplain this pull request.", - lastHandoffPrompt: "Explain this pull request.", - }, - "", - ), - ).toBe("check the migration first"); - }); - it("keeps the lines the reader marked up in the thread themselves", () => { const own = chip("file-comment:3"); const next = handoffReviewComments( @@ -1760,48 +1274,16 @@ describe("how the branch stands against its base", () => { ...overrides, }) as Parameters[0]; - it("offers both ways where the host and the reader both allow them", () => { - expect(resolveBaseFreshness(detail())).toEqual({ behindBy: 12, methods: ["merge", "rebase"] }); - }); - - it("says nothing about a branch that is already current", () => { - expect(resolveBaseFreshness(detail({ baseComparison: "up-to-date" }))).toBeNull(); - }); - it("says nothing where the host could not compare, rather than claiming it is current", () => { expect(resolveBaseFreshness(detail({ baseComparison: "unknown" }))).toBeNull(); expect(resolveBaseFreshness(detail({ baseComparison: undefined }))).toBeNull(); }); - it("leaves a conflicting branch to the conflicts row", () => { - expect(resolveBaseFreshness(detail({ mergeability: "conflicting" }))).toBeNull(); - }); - - it("says nothing where the host has no merge verdict yet", () => { - expect(resolveBaseFreshness(detail({ mergeability: "unknown" }))).toBeNull(); - }); - - it("says nothing about a merged or closed pull request", () => { - expect(resolveBaseFreshness(detail({ state: "merged" }))).toBeNull(); - expect(resolveBaseFreshness(detail({ state: "closed" }))).toBeNull(); - }); - it("narrows to what this reader may actually take", () => { expect( resolveBaseFreshness(detail({ viewerPermissions: { updateMethods: ["merge"] } }))?.methods, ).toEqual(["merge"]); }); - - it("still reports the news where the reader may take none of it", () => { - expect(resolveBaseFreshness(detail({ viewerPermissions: {} }))).toEqual({ - behindBy: 12, - methods: [], - }); - }); - - it("reports a count only where the host counted", () => { - expect(resolveBaseFreshness(detail({ behindBy: undefined }))?.behindBy).toBeNull(); - }); }); describe("pull request panel context beside a thread", () => { @@ -1870,6 +1352,7 @@ describe("pull request panel context beside a thread", () => { ); }); + // regression: #12320 PR panel actions escaped the current thread it("is the page for a pull request the thread is not linked to", () => { expect(pullRequestPanelContext(stackThread, surface(12320))).toBe("page"); expect(pullRequestPanelContext(stackThread, surface(10856, { repository: "acme/web" }))).toBe( @@ -1877,6 +1360,7 @@ describe("pull request panel context beside a thread", () => { ); }); + // regression: #12320 PR panel actions escaped the current thread it("is the page under another project's checkout of the same repository", () => { expect(pullRequestPanelContext(stackThread, surface(10856, { projectId: "proj-b" }))).toBe( "page", @@ -1950,6 +1434,7 @@ describe("pull request panel context beside a thread", () => { expect(onHost("github.example.com")).toMatchObject({ host: "github.example.com" }); }); + // regression: #15061 Pull request panel entry ignored the branch PR it("falls back to the branch pull request when the thread holds no link", () => { expect( threadPullRequestPanelTarget({ projectId: "proj-a", branchPullRequest: legacy(3) }), @@ -1959,36 +1444,6 @@ describe("pull request panel context beside a thread", () => { }); }); -describe("which actions need the host read again after they run", () => { - it("classifies every action the contract knows about", () => { - expect(PullRequestAction.literals.map(pullRequestActionNeedsHostRefresh)).toEqual( - PullRequestAction.literals.map( - (action) => action === "update-branch" || action === "approve-workflows", - ), - ); - }); - - it("sends update-branch back to the host, having moved the head commit", () => { - expect(pullRequestActionNeedsHostRefresh("update-branch")).toBe(true); - }); - - it("leaves every action that only changes metadata to the cheaper detail refresh", () => { - for (const action of [ - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - "merge", - "revert", - "delete-branch", - ] as const) { - expect(pullRequestActionNeedsHostRefresh(action)).toBe(false); - } - }); -}); - describe("when the source branch can be deleted", () => { const branchDetail = ( state: PullRequestState, @@ -2004,12 +1459,6 @@ describe("when the source branch can be deleted", () => { }, }); - it("offers it only after the pull request is merged or closed", () => { - expect(canDeletePullRequestBranch(branchDetail("merged"))).toBe(true); - expect(canDeletePullRequestBranch(branchDetail("closed"))).toBe(true); - expect(canDeletePullRequestBranch(branchDetail("open"))).toBe(false); - }); - it("needs both the host capability and the viewer's permission", () => { expect(canDeletePullRequestBranch(branchDetail("merged", { deleteBranch: false }))).toBe(false); expect(canDeletePullRequestBranch(branchDetail("merged", { allowed: false }))).toBe(false); @@ -2042,11 +1491,6 @@ describe("the compact row's single action slot", () => { ...overrides, }) as NonNullable[0]>; - it("offers Merge only for a clean pull request whose checks pass", () => { - expect(resolveThreadPanelPullRequestAction(openDetail())).toBe("merge"); - expect(resolveThreadPanelPullRequestAction(openDetail({ checks: [] }))).toBe("merge"); - }); - it("holds the slot while checks run rather than offering a merge that races them", () => { expect( resolveThreadPanelPullRequestAction( @@ -2089,33 +1533,6 @@ describe("the compact row's single action slot", () => { expect(resolveThreadPanelPullRequestAction(openDetail({ state: "merged" }))).toBeNull(); expect(resolveThreadPanelPullRequestAction(null)).toBeNull(); }); - - it("describes every live facet of the checks at once", () => { - expect(describePullRequestChecks([])).toBe("No checks reported"); - expect(describePullRequestChecks([check("success"), check("success")])).toBe( - "All checks passed", - ); - expect(describePullRequestChecks([check("success"), check("skipped")])).toBe("1 of 2 passing"); - expect( - describePullRequestChecks([ - ...Array.from({ length: 7 }, () => check("pending")), - ...Array.from({ length: 8 }, () => check("success")), - check("failure"), - ]), - ).toBe("7 of 16 running · 1 failed"); - expect(describePullRequestChecks([check("failure"), check("success")])).toBe("1 of 2 failing"); - expect(describePullRequestChecks([check("action-required")])).toBe("1 of 1 awaiting action"); - expect(describePullRequestChecks([check("action-required"), check("failure")])).toBe( - "1 of 2 awaiting action · 1 failed", - ); - }); - - it("reads the checks as one word, failing outranking running", () => { - expect(classifyPullRequestChecks([])).toBe("none"); - expect(classifyPullRequestChecks([check("success"), check("skipped")])).toBe("passing"); - expect(classifyPullRequestChecks([check("success"), check("pending")])).toBe("pending"); - expect(classifyPullRequestChecks([check("pending"), check("cancelled")])).toBe("failing"); - }); }); describe("cached pull request detail", () => { @@ -2208,16 +1625,6 @@ describe("cached pull request detail", () => { expect(storage.size()).toBe(21); }); - it("hydrates the last title, author, and counts so a reopen does not ghost the tab", () => { - const storage = makeStorage(); - writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); - const snapshot = readPullRequestDetailSnapshot(storage, "env-1", reference); - expect(snapshot?.title).toBe("Cache the title"); - expect(snapshot?.author?.login).toBe("octocat"); - expect(snapshot?.additions).toBe(12); - expect(snapshot?.deletions).toBe(3); - }); - it("reuses a host-qualified snapshot when reopening a thread link without a host", () => { const storage = makeStorage(); writePullRequestDetailSnapshot( @@ -2251,21 +1658,6 @@ describe("cached pull request detail", () => { ).toBe(explicit); }); - it("leaves server-resolved Azure SSH references unchanged", () => { - expect( - resolvePullRequestReferenceHost(reference, { - canonicalKey: "ssh.dev.azure.com/v3/org/project/web", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "git@ssh.dev.azure.com:v3/org/project/web", - }, - provider: SourceControlProviderKind.make("azure-devops"), - }), - ).toBe(reference); - expect(resolvePullRequestReferenceHost(reference, undefined)).toBe(reference); - }); - it("hydrates legacy hostless snapshots only for the matching host", () => { const storage = makeStorage(); writePullRequestDetailSnapshot(storage, "env-1", reference, detail()); @@ -2303,6 +1695,7 @@ describe("cached pull request detail", () => { ).toBeNull(); }); + // regression: #12168 cached PR details were missed across entry points on custom ports it.each(["github", "gitlab"] as const)( "retains portless %s snapshot identities for custom web ports", (providerName) => { @@ -2319,15 +1712,6 @@ describe("cached pull request detail", () => { }, ); - it("keeps a cached tab painted while the live read replaces the counts", () => { - const cached = detail(); - const live = detail({ additions: 40, deletions: 9, title: "Cache the title" }); - expect(resolveDisplayedPullRequestDetail({ live, cached, reference })?.additions).toBe(40); - expect(resolveDisplayedPullRequestDetail({ live: null, cached, reference })?.additions).toBe( - 12, - ); - }); - it("does not paint another change request's snapshot", () => { expect( resolveDisplayedPullRequestDetail({ @@ -2375,6 +1759,7 @@ describe("cached pull request detail", () => { expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); }); + // regression: #9294 corrupt cached PR chrome broke reopening it("shrugs off corrupt storage and no storage at all", () => { const storage = makeStorage(); storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index be673ba93e45..4e11e04e1d78 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -24,11 +24,6 @@ describe("isLineInFileDiff", () => { { deletionStart: 40, deletionCount: 0, additionStart: 42, additionCount: 2 }, ]); - it("places a line inside a hunk, on the side that hunk counts", () => { - expect(isLineInFileDiff(file, "right", 12)).toBe(true); - expect(isLineInFileDiff(file, "left", 11)).toBe(true); - }); - it("includes the first line of a hunk and excludes the one past its last", () => { expect(isLineInFileDiff(file, "right", 10)).toBe(true); expect(isLineInFileDiff(file, "right", 14)).toBe(true); @@ -42,63 +37,20 @@ describe("isLineInFileDiff", () => { expect(isLineInFileDiff(file, "right", 43)).toBe(true); expect(isLineInFileDiff(file, "left", 40)).toBe(false); }); - - it("places nothing in a file whose hunks the host withheld", () => { - expect(isLineInFileDiff(fileWithHunks([]), "right", 1)).toBe(false); - }); }); describe("isFileDiffCollapsed", () => { - const NO_TOGGLES: ReadonlySet = new Set(); - - it("opens every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(false); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(false); - }); - - it("opens every file once the toolbar has asked for it", () => { - expect(isFileDiffCollapsed("a.ts", "expanded", NO_TOGGLES)).toBe(false); - expect(isFileDiffCollapsed("b.ts", "expanded", NO_TOGGLES)).toBe(false); - }); - - it("folds every file again on the second press", () => { - expect(isFileDiffCollapsed("a.ts", "folded", NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); - }); - + // regression: #9174 diffs now expand by default; a reader's fold must survive new slices it("keeps a file the reader folded closed as the next slice arrives", () => { const toggled = new Set(["b.ts"]); expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(true); expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(false); }); - - it("still answers to a toggle after either toolbar press", () => { - expect(isFileDiffCollapsed("a.ts", "expanded", new Set(["a.ts"]))).toBe(true); - expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); - }); }); describe("toggleFileDiffFoldForViewed", () => { - it("puts a file away when it is ticked off", () => { - expect([...toggleFileDiffFoldForViewed("a.ts", true, null, new Set())]).toEqual(["a.ts"]); - }); - - it("brings a file back when the tick is taken off", () => { - expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set(["a.ts"]))]).toEqual([]); - }); - - it("leaves the fold alone when it already says what the tick does", () => { - const folded = new Set(["a.ts"]); - expect(toggleFileDiffFoldForViewed("a.ts", true, null, folded)).toBe(folded); - }); - it("moves against whatever the toolbar last asked for", () => { expect([...toggleFileDiffFoldForViewed("a.ts", true, "expanded", new Set())]).toEqual(["a.ts"]); expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); }); - - it("touches only the file that was ticked", () => { - const toggled = new Set(["a.ts", "b.ts"]); - expect([...toggleFileDiffFoldForViewed("a.ts", false, null, toggled)]).toEqual(["b.ts"]); - }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestDiffError.test.ts b/apps/web/src/components/pullRequest/pullRequestDiffError.test.ts index 26a4423e003e..b620a5a4c636 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiffError.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiffError.test.ts @@ -14,20 +14,13 @@ describe("describePullRequestDiffError", () => { expect(result.description).not.toContain("PullRequestOperationError"); }); + // regression: #19945 diff load failures from a provider quota showed a generic error it("recognizes a provider's API quota error", () => { expect(describePullRequestDiffError("GitHub API rate limit exceeded.", "GitHub").title).toBe( "GitHub rate limit reached", ); }); - it("uses the actual host's name for a rate limit", () => { - const result = describePullRequestDiffError("Too many requests", "GitLab"); - - expect(result.title).toBe("GitLab rate limit reached"); - expect(result.description).toContain("on GitLab"); - expect(result.description).not.toContain("GitHub"); - }); - it("does not blame a rate limit for an unknown connection failure", () => { const result = describePullRequestDiffError( "Failed to fetch remote environment endpoint http://localhost:49477/api/pull-requests/diff (TypeError: Failed to fetch).", diff --git a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts index b6c677e10636..e58c01b74ce6 100644 --- a/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts @@ -71,10 +71,6 @@ function comment(overrides: Partial = {}): PullRequestCommen } describe("canEditPullRequestChangeRequest", () => { - it("lets the author rewrite their own change request", () => { - expect(canEditPullRequestChangeRequest(subject())).toBe(true); - }); - it("matches the author regardless of case", () => { expect(canEditPullRequestChangeRequest(subject({ viewer: "OctoCat" }))).toBe(true); }); @@ -136,10 +132,6 @@ describe("canEditPullRequestChangeRequest", () => { expect(canEditPullRequestChangeRequest(subject({ capabilities: capabilities() }))).toBe(false); }); - it("refuses where the host did not say who the reader is", () => { - expect(canEditPullRequestChangeRequest(subject({ viewer: undefined }))).toBe(false); - }); - it("still allows a merger the host could not name", () => { expect( canEditPullRequestChangeRequest( @@ -150,10 +142,6 @@ describe("canEditPullRequestChangeRequest", () => { }); describe("canEditPullRequestComment", () => { - it("lets the reader rewrite their own conversation comment", () => { - expect(canEditPullRequestComment(subject(), comment())).toBe(true); - }); - it("lets the reader rewrite their own remark on a line", () => { expect(canEditPullRequestComment(subject(), comment({ kind: "review-comment" }))).toBe(true); }); @@ -189,29 +177,9 @@ describe("canEditPullRequestComment", () => { ).toBe(false); }); - it("refuses a remark the host attributes to nobody", () => { - expect(canEditPullRequestComment(subject(), comment({ author: null }))).toBe(false); - }); - it("refuses a review's own summary", () => { expect(canEditPullRequestComment(subject(), comment({ kind: "review" }))).toBe(false); }); - - it("refuses where the host cannot rewrite a remark at all", () => { - expect( - canEditPullRequestComment( - subject({ capabilities: capabilities({ changeRequest: true, comment: false }) }), - comment(), - ), - ).toBe(false); - expect(canEditPullRequestComment(subject({ capabilities: capabilities() }), comment())).toBe( - false, - ); - }); - - it("refuses where the host did not say who the reader is", () => { - expect(canEditPullRequestComment(subject({ viewer: undefined }), comment())).toBe(false); - }); }); describe("canResolvePullRequestThread", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index fe9b07c2d97e..967841af34e6 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -36,27 +36,6 @@ describe("orderDiffFiles", () => { ).toEqual([...source, ...tests, ...generated]); }); - it("answers an empty diff with an empty order", () => { - expect(order([])).toEqual([]); - }); - - it("puts a dependency before what imports it", () => { - expect(order([file("src/a.ts", ['import { b } from "./b";']), file("src/b.ts")])).toEqual([ - "src/b.ts", - "src/a.ts", - ]); - }); - - it("follows a chain the whole way down", () => { - expect( - order([ - file("src/a.ts", ['import { b } from "./b";']), - file("src/b.ts", ['import { c } from "./c";']), - file("src/c.ts"), - ]), - ).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]); - }); - it("resolves a specifier that climbs out of the importer's directory", () => { expect( order([file("src/ui/a.ts", ['import { b } from "../lib/b";']), file("src/lib/b.ts")]), @@ -112,14 +91,6 @@ describe("orderDiffFiles", () => { ).toEqual(["src/a.ts", "src/b.ts"]); }); - it("clusters unrelated files by path", () => { - expect(order([file("src/ui/b.ts"), file("src/lib/z.ts"), file("src/lib/a.ts")])).toEqual([ - "src/lib/a.ts", - "src/lib/z.ts", - "src/ui/b.ts", - ]); - }); - it("keeps the tiers apart and orders tests by the source they cover", () => { expect( order([ diff --git a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts index cbf36cc85884..37f13c702296 100644 --- a/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFilesViewed.logic.test.ts @@ -5,7 +5,6 @@ import { isStaleViewedState, revertFileViewedOverlay, settleFileViewedOverlay, - toFileViewedBatch, toFileViewedStates, type FileViewedOverlay, } from "./pullRequestFilesViewed.logic"; @@ -24,11 +23,6 @@ const states = toFileViewedStates({ }); describe("isFileViewed", () => { - it("follows the host for a file the reader has not pressed", () => { - expect(isFileViewed("a.ts", states, NO_OVERLAY)).toBe(true); - expect(isFileViewed("b.ts", states, NO_OVERLAY)).toBe(false); - }); - it("reads a file pushed to since it was cleared as unread", () => { expect(isFileViewed("c.ts", states, NO_OVERLAY)).toBe(false); expect(isStaleViewedState(states?.get("c.ts"))).toBe(true); @@ -39,11 +33,6 @@ describe("isFileViewed", () => { expect(isFileViewed("b.ts", states, new Map([["b.ts", true]]))).toBe(true); expect(isFileViewed("a.ts", states, new Map([["a.ts", false]]))).toBe(false); }); - - it("answers a file the host has said nothing about, before its answer arrives", () => { - expect(isFileViewed("z.ts", null, NO_OVERLAY)).toBe(false); - expect(isFileViewed("z.ts", null, new Map([["z.ts", true]]))).toBe(true); - }); }); describe("settleFileViewedOverlay", () => { @@ -57,13 +46,6 @@ describe("settleFileViewedOverlay", () => { expect(settled.size).toBe(0); }); - it("keeps a press the host still disagrees with", () => { - const overlay = new Map([["b.ts", true]]); - expect(settleFileViewedOverlay(overlay, states, NOTHING_PENDING, NOTHING_ANSWERED)).toBe( - overlay, - ); - }); - it("keeps a press the host cannot have heard yet", () => { const overlay = new Map([["a.ts", false]]); const settled = settleFileViewedOverlay(overlay, states, new Set(["a.ts"]), NOTHING_ANSWERED); @@ -97,27 +79,6 @@ describe("settleFileViewedOverlay", () => { const settled = settleFileViewedOverlay(overlay, states, new Set(["c.ts"]), new Set(["c.ts"])); expect(settled.get("c.ts")).toBe(true); }); - - it("holds everything until the host has answered at all", () => { - const overlay = new Map([["a.ts", true]]); - expect(settleFileViewedOverlay(overlay, null, NOTHING_PENDING, NOTHING_ANSWERED)).toBe(overlay); - }); -}); - -describe("toFileViewedBatch", () => { - it("carries both directions in one batch", () => { - expect( - toFileViewedBatch( - new Map([ - ["a.ts", false], - ["b.ts", true], - ]), - ), - ).toEqual([ - { path: "a.ts", viewed: false }, - { path: "b.ts", viewed: true }, - ]); - }); }); describe("revertFileViewedOverlay", () => { @@ -148,9 +109,4 @@ describe("revertFileViewedOverlay", () => { const overlay = new Map([["a.ts", true]]); expect(revertFileViewedOverlay(overlay, batch, new Set(["b.ts"]))).toBe(overlay); }); - - it("leaves a path the request never carried", () => { - const overlay = new Map([["c.ts", true]]); - expect(revertFileViewedOverlay(overlay, batch, both)).toBe(overlay); - }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index e1d95b839330..002b304d2140 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -17,44 +17,31 @@ import { findScopedProject, mergePullRequestLists, pullRequestEntryKey, - pullRequestEnvironmentSetKey, groupPullRequestsByInvolvement, - matchesPullRequestFilters, - matchesPullRequestQuery, pullRequestStatsBatches, pullRequestStatsKeysToRequest, pullRequestStatsRefreshBatches, - pullRequestStatsRequestBatches, mergePullRequestDiffStats, - narrowPullRequestsToFilters, partitionPullRequestsWithPriority, readPullRequestListSnapshot, writePullRequestListSnapshot, rankPullRequestMatches, rankPullRequestsBlockedOnAuthor, - rankPullRequestsBlockedOnReviewer, rankPullRequestsByMergeReadiness, - scorePullRequestMatch, sortPullRequestGroups, - retainVisiblePullRequestStatsBatches, - withDiffStat, - resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, type EnvironmentPullRequestEntry, applyPullRequestOverrides, - pullRequestOverrideAfterAction, reusePullRequestEntries, settlePullRequestOverrides, } from "./pullRequestList.logic"; import { - pullRequestListPreferences, readPullRequestListPreferences, writePullRequestListPreferences, } from "./pullRequestListPreferences"; const VIEWERS = { "github.com": "Bilal" } as const; -const NO_VIEWERS = {} as const; function entry( overrides: Partial & Pick, @@ -85,25 +72,6 @@ function entry( } describe("visible pull request line-count targets", () => { - it("reuses counts supplied by the listing and only requests missing counts", () => { - const entries = [ - entry({ number: 1, additions: 12, deletions: 0 }), - entry({ number: 2, additions: 0, deletions: 7 }), - entry({ number: 3, additions: 0, deletions: 0 }), - ]; - const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); - const keys = pullRequestStatsKeysToRequest( - entriesByKey, - new Set(entries.map(pullRequestEntryKey)), - [], - new Map(), - ); - - expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([ - { projectId: "project-1", repository: "pingdotgg/t3code", number: 3 }, - ]); - }); - it("does not request a row again after its received batch is pruned", () => { const entries = [ entry({ additions: 0, deletions: 0, number: 1 }), @@ -134,23 +102,6 @@ describe("visible pull request line-count targets", () => { ]); }); - it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => { - const entries = Array.from({ length: 500 }, (_, index) => - entry({ additions: 0, deletions: 0, number: index + 1 }), - ); - const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); - const batches = entries.map( - (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!, - ); - const visibleKeys = new Set(entries.slice(-12).map(pullRequestEntryKey)); - - const retained = retainVisiblePullRequestStatsBatches(batches, visibleKeys); - expect(retained).toHaveLength(12); - expect(retained.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual( - entries.slice(-12).map((item) => item.number), - ); - }); - it("keeps every per-environment batch within the stats contract limit", () => { const entries = Array.from({ length: 501 }, (_, index) => entry({ additions: 0, deletions: 0, number: index + 1 }), @@ -165,84 +116,6 @@ describe("visible pull request line-count targets", () => { expect(batches.flatMap((batch) => [...batch.keys])).toEqual(entries.map(pullRequestEntryKey)); }); - it("selects visible rows for date modes and every uncached row for size modes", () => { - const entries = [ - entry({ additions: 0, deletions: 0, number: 1 }), - entry({ additions: 0, deletions: 0, number: 2 }), - entry({ additions: 0, deletions: 0, number: 3, environmentId: "env-2" as EnvironmentId }), - ]; - const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); - const [firstKey, secondKey] = entries.map(pullRequestEntryKey); - const cachedStats = mergePullRequestDiffStats(new Map(), [ - { - environmentId: ENV_1, - projectId: "project-1", - number: 1, - additions: 1, - deletions: 1, - }, - ]); - - const visible = pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: new Set([firstKey!, secondKey!]), - policy: "visible", - activeBatches: [], - statsByRow: cachedStats, - }); - expect(visible.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2]); - - const eager = pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: new Set([firstKey!]), - policy: "eager", - activeBatches: [], - statsByRow: cachedStats, - }); - expect(eager.map((batch) => batch.environmentId)).toEqual([ENV_1, "env-2"]); - expect(eager.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2, 3]); - }); - - it("refreshes only visible rows unless size sorting needs every loaded row", () => { - const entries = [ - entry({ additions: 0, deletions: 0, number: 1 }), - entry({ additions: 0, deletions: 0, number: 2 }), - entry({ additions: 0, deletions: 0, number: 3 }), - ]; - const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); - const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); - const cachedStats = mergePullRequestDiffStats( - new Map(), - entries.map((item) => ({ - environmentId: ENV_1, - projectId: item.projectId, - number: item.number, - additions: item.additions, - deletions: item.deletions, - })), - ); - - const visible = pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: visibleKeys, - policy: "visible", - activeBatches: [], - statsByRow: cachedStats, - refresh: true, - }); - expect(visible[0]?.input.refs.map((ref) => ref.number)).toEqual([2]); - - const eager = pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: visibleKeys, - policy: "eager", - activeBatches: [], - statsByRow: cachedStats, - refresh: true, - }); - expect(eager[0]?.input.refs.map((ref) => ref.number)).toEqual([1, 2, 3]); - }); - it("ignores a late refresh after the filter or stats policy changes", () => { const entries = [ entry({ additions: 0, deletions: 0, number: 1 }), @@ -264,51 +137,6 @@ describe("visible pull request line-count targets", () => { expect(refresh({ key: "closed", policy: "visible" })).toBeNull(); expect(refresh({ key: "open", policy: "eager" })).toBeNull(); }); - - it("does not add request batches again while rows are active or cached", () => { - const entries = [ - entry({ additions: 0, deletions: 0, number: 1 }), - entry({ additions: 0, deletions: 0, number: 2 }), - ]; - const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); - const first = pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: new Set(), - policy: "eager", - activeBatches: [], - statsByRow: new Map(), - }); - - expect( - pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: new Set(), - policy: "eager", - activeBatches: first, - statsByRow: new Map(), - }), - ).toEqual([]); - - const cachedStats = mergePullRequestDiffStats( - new Map(), - entries.map((item) => ({ - environmentId: ENV_1, - projectId: item.projectId, - number: item.number, - additions: item.additions, - deletions: item.deletions, - })), - ); - expect( - pullRequestStatsRequestBatches({ - entriesByKey, - candidateKeys: new Set(entries.map(pullRequestEntryKey)), - policy: "visible", - activeBatches: [], - statsByRow: cachedStats, - }), - ).toEqual([]); - }); }); describe("pull request filter facets", () => { @@ -379,28 +207,6 @@ describe("pull request filter facets", () => { }); describe("pull request involvement filtering", () => { - const entries = [ - entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), - entry({ number: 2, viewerReviewRequested: true }), - entry({ number: 3 }), - ]; - - it("matches the viewer's own pull requests case-insensitively", () => { - expect( - filterPullRequestsByInvolvement(entries, VIEWERS, "authored").map((item) => item.number), - ).toEqual([1]); - }); - - it("returns nothing for Authored when the viewer is unknown", () => { - expect(filterPullRequestsByInvolvement(entries, NO_VIEWERS, "authored")).toEqual([]); - }); - - it("uses the server-computed review-request flag for Reviewing", () => { - expect( - filterPullRequestsByInvolvement(entries, VIEWERS, "reviewing").map((item) => item.number), - ).toEqual([2]); - }); - it("does not treat a matching login on another host as the viewer", () => { const mixed = [ entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), @@ -415,235 +221,6 @@ describe("pull request involvement filtering", () => { filterPullRequestsByInvolvement(mixed, VIEWERS, "authored").map((item) => item.number), ).toEqual([1]); }); - - it("leaves the superset untouched for All", () => { - expect(filterPullRequestsByInvolvement(entries, VIEWERS, "all")).toHaveLength(3); - }); - - it("keeps two hosts of one provider kind as two accounts", () => { - const mixed = [ - entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), - entry({ - number: 2, - host: "github.acme.dev", - author: { login: "Bilal", name: null, avatarUrl: null }, - }), - ]; - expect( - filterPullRequestsByInvolvement(mixed, VIEWERS, "authored").map((item) => item.number), - ).toEqual([1]); - }); -}); - -describe("pull request grouping", () => { - it("buckets authored above review-requested and drops empty groups", () => { - const groups = groupPullRequestsByInvolvement( - [ - entry({ number: 1, author: { login: "bilal", name: null, avatarUrl: null } }), - entry({ number: 2, viewerReviewRequested: true }), - ], - VIEWERS, - ); - expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["authored", 1], - ["reviewRequested", 1], - ]); - }); - - it("puts everything in Others when the viewer is unknown", () => { - const groups = groupPullRequestsByInvolvement([entry({ number: 1 })], NO_VIEWERS); - expect(groups.map((group) => group.key)).toEqual(["others"]); - }); - - it("counts an authored pull request once, even with a review request on it", () => { - const groups = groupPullRequestsByInvolvement( - [ - entry({ - number: 1, - author: { login: "bilal", name: null, avatarUrl: null }, - viewerReviewRequested: true, - }), - ], - VIEWERS, - ); - expect(groups.map((group) => group.key)).toEqual(["authored"]); - }); -}); - -describe("pull request search", () => { - const target = entry({ - number: 4711, - title: "Restore sidebar actions", - headBranch: "fix/sidebar", - }); - - it("matches the number with or without the leading hash", () => { - expect(matchesPullRequestQuery(target, "#4711")).toBe(true); - expect(matchesPullRequestQuery(target, "4711")).toBe(true); - }); - - it("matches title, branch and author case-insensitively", () => { - expect(matchesPullRequestQuery(target, "SIDEBAR")).toBe(true); - expect(matchesPullRequestQuery(target, "fix/side")).toBe(true); - expect(matchesPullRequestQuery(target, "octocat")).toBe(true); - }); - - it("ignores surrounding whitespace and rejects non-matches", () => { - expect(matchesPullRequestQuery(target, " ")).toBe(true); - expect(matchesPullRequestQuery(target, "kanban")).toBe(false); - }); -}); - -describe("carrying rows already read into filters nothing has answered yet", () => { - const rows = [ - entry({ number: 1 }), - entry({ number: 2, state: "merged" }), - entry({ number: 3, state: "closed" }), - entry({ number: 4, host: "github.acme.dev" }), - entry({ number: 5, projectId: "project-2" as PullRequestListEntry["projectId"] }), - ]; - const everything = { state: "all", projectId: undefined, host: undefined } as const; - - it("never lets a merged pull request sit under Open", () => { - expect( - narrowPullRequestsToFilters(rows, { ...everything, state: "open" }).map((row) => row.number), - ).toEqual([1, 4, 5]); - }); - - it("keeps only the state that was asked for", () => { - expect( - narrowPullRequestsToFilters(rows, { ...everything, state: "merged" }).map( - (row) => row.number, - ), - ).toEqual([2]); - }); - - it("narrows to one host, which two installs of one kind cannot share", () => { - expect( - narrowPullRequestsToFilters(rows, { ...everything, host: "github.acme.dev" }).map( - (row) => row.number, - ), - ).toEqual([4]); - }); - - it("narrows to one project", () => { - expect( - narrowPullRequestsToFilters(rows, { ...everything, projectId: "project-2" }).map( - (row) => row.number, - ), - ).toEqual([5]); - }); - - it("holds on to everything when nothing narrows it", () => { - expect(narrowPullRequestsToFilters(rows, everything)).toEqual(rows); - }); -}); - -describe("narrowing rows by the filters a host may not have applied", () => { - const rows = [ - entry({ number: 1 }), - entry({ number: 2, isDraft: true }), - entry({ number: 3, reviewDecision: "approved" }), - entry({ - number: 4, - labels: [{ name: "Needs design", color: null }], - author: { login: "Hubot", name: null, avatarUrl: null }, - reviewDecision: "changes-requested", - }), - ]; - const narrow = (filters: Parameters[1]) => - rows.filter((row) => matchesPullRequestFilters(row, filters)).map((row) => row.number); - - it("keeps or drops drafts as asked", () => { - expect(narrow({ draft: "only" })).toEqual([2]); - expect(narrow({ draft: "hide" })).toEqual([1, 3, 4]); - }); - - it("keeps the review state that was asked for, and no reviews means none at all", () => { - expect(narrow({ review: "approved" })).toEqual([3]); - expect(narrow({ review: "changes-requested" })).toEqual([4]); - expect(narrow({ review: "none" })).toEqual([1, 2]); - }); - - it("matches labels and authors however either was capitalized", () => { - expect(narrow({ labels: [["needs DESIGN"]] })).toEqual([4]); - expect(narrow({ excludedLabels: ["needs design"] })).toEqual([1, 2, 3]); - expect(narrow({ author: "hubot" })).toEqual([4]); - }); - - it("resolves author:me to the given viewer, case-insensitively", () => { - const mine = entry({ number: 30, author: { login: "Bilal", name: null, avatarUrl: null } }); - const theirs = entry({ number: 31, author: { login: "Hubot", name: null, avatarUrl: null } }); - expect(matchesPullRequestFilters(mine, { author: "me" }, "bilal")).toBe(true); - expect(matchesPullRequestFilters(theirs, { author: "me" }, "bilal")).toBe(false); - }); - - it("resolves author:@me the same way as author:me", () => { - const mine = entry({ number: 32, author: { login: "Bilal", name: null, avatarUrl: null } }); - const theirs = entry({ number: 33, author: { login: "Hubot", name: null, avatarUrl: null } }); - expect(matchesPullRequestFilters(mine, { author: "@me" }, "Bilal")).toBe(true); - expect(matchesPullRequestFilters(theirs, { author: "@me" }, "Bilal")).toBe(false); - }); - - it("compares author:me against the literal name when there is no viewer", () => { - const literalMe = entry({ number: 34, author: { login: "me", name: null, avatarUrl: null } }); - const someoneElse = entry({ - number: 35, - author: { login: "Bilal", name: null, avatarUrl: null }, - }); - expect(matchesPullRequestFilters(literalMe, { author: "me" })).toBe(true); - expect(matchesPullRequestFilters(someoneElse, { author: "me" })).toBe(false); - }); - - it("takes a group of labels as an either-or", () => { - const sized = [ - entry({ number: 10, labels: [{ name: "size:S", color: null }] }), - entry({ number: 11, labels: [{ name: "size:XS", color: null }] }), - entry({ number: 12, labels: [{ name: "size:L", color: null }] }), - ]; - const kept = (labels: ReadonlyArray>) => - sized.filter((row) => matchesPullRequestFilters(row, { labels })).map((row) => row.number); - - expect(kept([["size:S", "size:XS"]])).toEqual([10, 11]); - expect(kept([["size:XXL"]])).toEqual([]); - }); - - it("takes two groups as an and, each satisfied on its own", () => { - const row = entry({ - number: 20, - labels: [ - { name: "size:S", color: null }, - { name: "bug", color: null }, - ], - }); - expect(matchesPullRequestFilters(row, { labels: [["size:S", "size:XS"], ["bug"]] })).toBe(true); - expect(matchesPullRequestFilters(row, { labels: [["size:S"], ["wip"]] })).toBe(false); - }); - - it("holds on to every row for a filter no row carries the answer to", () => { - expect(narrow({ checks: "passing" })).toEqual([1, 2, 3, 4]); - }); -}); - -describe("resolveProjectScope", () => { - const projects = [{ id: "p1" }, { id: "p2" }]; - - it("keeps an id the environment has", () => { - expect(resolveProjectScope("p2", projects, true)).toBe("p2"); - }); - - it("drops an id from another environment", () => { - expect(resolveProjectScope("p9", projects, false)).toBe("p9"); - expect(resolveProjectScope("p9", projects, true)).toBeUndefined(); - }); - - it("drops an id once the environment is known to have no projects", () => { - expect(resolveProjectScope("p9", [], true)).toBeUndefined(); - }); - - it("keeps an id while the projects are still unknown", () => { - expect(resolveProjectScope("p9", [], false)).toBe("p9"); - }); }); describe("ranking what a search found", () => { @@ -665,35 +242,6 @@ describe("ranking what a search found", () => { rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number), ).toEqual([1, 2]); }); - - it("finds the words in any order, which is how people type them", () => { - expect( - scorePullRequestMatch(row({ title: "The wizard that welcomes" }), "welcome wizard"), - ).toBe(70); - expect( - scorePullRequestMatch(row({ title: "Wizard for the welcome flow" }), "welcome wizard"), - ).toBe(70); - expect(scorePullRequestMatch(row({ title: "A wizard, of sorts" }), "welcome wizard")).toBe(30); - }); - - it("answers a number with the pull request that has it, and nothing else", () => { - expect(scorePullRequestMatch(row({ number: 42 }), "#42")).toBe(100); - expect(scorePullRequestMatch(row({ number: 42 }), "43")).toBe(0); - }); - - it("keeps recency where nothing tells two results apart", () => { - const older = row({ number: 1, title: "no match here", updatedAt: "2026-07-01T00:00:00Z" }); - const newer = row({ number: 2, title: "none here either", updatedAt: "2026-08-01T00:00:00Z" }); - expect(rankPullRequestMatches([older, newer], "wizard").map((entry) => entry.number)).toEqual([ - 2, 1, - ]); - }); - - it("leaves a listing with no search in the order it arrived", () => { - const first = row({ number: 1, updatedAt: "2026-07-01T00:00:00Z" }); - const second = row({ number: 2, updatedAt: "2026-08-01T00:00:00Z" }); - expect(rankPullRequestMatches([first, second], " ")).toEqual([first, second]); - }); }); describe("default merge-readiness ranking", () => { @@ -745,57 +293,6 @@ describe("default merge-readiness ranking", () => { ).toEqual([4, 3, 5, 2, 6, 1]); }); - it("uses recency when readiness and diff size tie", () => { - const older = entry({ number: 1, checksState: "passing" }); - const newer = entry({ - number: 2, - checksState: "passing", - updatedAt: "2026-08-01T00:00:00Z", - }); - - expect(rankPullRequestsByMergeReadiness([older, newer]).map((row) => row.number)).toEqual([ - 2, 1, - ]); - }); - - it("ranks smaller measured diffs first within a readiness tier as counts arrive", () => { - const larger = entry({ - number: 1, - checksState: "passing", - reviewDecision: "approved", - additions: 1, - deletions: 49, - updatedAt: "2026-09-01T00:00:00Z", - }); - const smaller = entry({ - number: 2, - checksState: "passing", - reviewDecision: "approved", - additions: 3, - deletions: 2, - updatedAt: "2026-08-01T00:00:00Z", - }); - const unknown = entry({ - number: 3, - checksState: "passing", - reviewDecision: "approved", - additions: 0, - deletions: 0, - updatedAt: "2026-09-02T00:00:00Z", - }); - - expect( - rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), - ).toEqual([2, 1, 3]); - expect( - rankPullRequestsByMergeReadiness([ - larger, - { ...unknown, additions: 1, deletions: 0 }, - smaller, - ]).map((row) => row.number), - ).toEqual([3, 2, 1]); - }); - it("distinguishes measured empty diffs from missing counts when sorting groups", () => { const unknown = entry({ number: 1, additions: 0, deletions: 0 }); const measured = entry({ number: 2 }); @@ -812,6 +309,7 @@ describe("default merge-readiness ranking", () => { ); }); + // regression: #9453 authored pull requests were not prioritized it("keeps authored work first and ranks each group by readiness", () => { const authoredWaiting = entry({ number: 1, checksState: "pending" }); const authoredReady = entry({ @@ -836,38 +334,6 @@ describe("default merge-readiness ranking", () => { expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); }); - - it.each([ - ["updated", [1, 2]], - ["newest", [2, 1]], - ["oldest", [1, 2]], - ["largest", [1, 2]], - ["smallest", [2, 1]], - ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { - const olderLarger = entry({ - number: 1, - additions: 20, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-08-01T00:00:00Z", - }); - const newerSmaller = entry({ - number: 2, - additions: 2, - createdAt: "2026-08-01T00:00:00Z", - updatedAt: "2026-07-01T00:00:00Z", - }); - const sorted = sortPullRequestGroups( - [ - { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, - { key: "others", label: "Others", entries: [entry({ number: 3 })] }, - ], - sort, - "", - ); - - expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); - expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); - }); }); describe("blocked-on-me ranking", () => { @@ -914,113 +380,6 @@ describe("blocked-on-me ranking", () => { ]).map((row) => row.number), ).toEqual([1, 3, 9, 2, 5, 6, 4, 8, 7]); }); - - it("ranks reviewer work with open rows first, newest first", () => { - const olderOpen = entry({ number: 1, updatedAt: "2026-08-01T00:00:00Z" }); - const newerOpen = entry({ number: 2, updatedAt: "2026-08-02T00:00:00Z" }); - const olderFinished = entry({ number: 3, state: "merged", updatedAt: "2026-08-01T00:00:00Z" }); - const newerFinished = entry({ number: 4, state: "closed", updatedAt: "2026-08-03T00:00:00Z" }); - - expect( - rankPullRequestsBlockedOnReviewer([newerFinished, newerOpen, olderFinished, olderOpen]).map( - (row) => row.number, - ), - ).toEqual([2, 1, 4, 3]); - }); - - it("ranks blocked groups according to their involvement", () => { - const authored = [ - entry({ number: 1, isDraft: true }), - entry({ number: 2, checksState: "failing" }), - ]; - const reviewing = [ - entry({ number: 3, updatedAt: "2026-08-02T00:00:00Z" }), - entry({ number: 4, updatedAt: "2026-08-01T00:00:00Z" }), - ]; - const others = [ - entry({ number: 5, updatedAt: "2026-08-02T00:00:00Z" }), - entry({ number: 6, isDraft: true }), - ]; - const groups = [ - { key: "authored", label: "Authored", entries: authored }, - { key: "reviewRequested", label: "Review requested", entries: reviewing }, - { key: "others", label: "Others", entries: others }, - ] as const; - - expect( - sortPullRequestGroups(groups, "blocked", "", undefined, "all").map((group) => - group.entries.map((row) => row.number), - ), - ).toEqual([ - [2, 1], - [3, 4], - [5, 6], - ]); - expect( - sortPullRequestGroups([groups[2]], "blocked", "", undefined, "authored")[0]!.entries.map( - (row) => row.number, - ), - ).toEqual([6, 5]); - expect( - sortPullRequestGroups([groups[2]], "blocked", "", undefined, "reviewing")[0]!.entries.map( - (row) => row.number, - ), - ).toEqual([5, 6]); - expect(sortPullRequestGroups(groups, "blocked", "needle", undefined, "authored")).toEqual( - groups, - ); - }); -}); - -describe("line counts that arrive after the rows", () => { - const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]); - - it("fills in a row whose host left the counts for later", () => { - const row = entry({ number: 7, additions: 0, deletions: 0 }); - expect(withDiffStat(row, stats)).toEqual({ ...row, additions: 42, deletions: 3 }); - }); - - it("leaves a row the host already counted alone", () => { - const row = entry({ number: 7, additions: 1, deletions: 1 }); - expect(withDiffStat(row, stats)).toBe(row); - }); - - it("leaves a row whose counts have not arrived as it is", () => { - const row = entry({ number: 9, additions: 0, deletions: 0 }); - expect(withDiffStat(row, stats)).toBe(row); - }); -}); - -describe("merging line counts across keyed stats queries", () => { - it("keeps counts already held while a fresh batch says nothing about them", () => { - const held = mergePullRequestDiffStats(new Map(), [ - { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 }, - { environmentId: "env-1", projectId: "project-1", number: 2, additions: 5, deletions: 1 }, - ]); - const merged = mergePullRequestDiffStats(held, []); - expect(merged.get("env-1 project-1 1")).toEqual({ additions: 10, deletions: 2 }); - expect(merged.get("env-1 project-1 2")).toEqual({ additions: 5, deletions: 1 }); - }); - - it("replaces a count once its replacement arrives, keeping its neighbours", () => { - const held = mergePullRequestDiffStats(new Map(), [ - { environmentId: "env-1", projectId: "project-1", number: 1, additions: 10, deletions: 2 }, - ]); - const merged = mergePullRequestDiffStats(held, [ - { environmentId: "env-1", projectId: "project-1", number: 1, additions: 11, deletions: 2 }, - { environmentId: "env-1", projectId: "project-1", number: 3, additions: 7, deletions: 0 }, - ]); - expect(merged.get("env-1 project-1 1")).toEqual({ additions: 11, deletions: 2 }); - expect(merged.get("env-1 project-1 3")).toEqual({ additions: 7, deletions: 0 }); - }); - - it("does not mutate the map it was handed", () => { - const held = new Map([["env-1 project-1 1", { additions: 1, deletions: 1 }]]); - mergePullRequestDiffStats(held, [ - { environmentId: "env-1", projectId: "project-1", number: 1, additions: 2, deletions: 2 }, - ]); - expect(held.get("env-1 project-1 1")).toEqual({ additions: 1, deletions: 1 }); - }); }); describe("partitioning with the hosts' own priority reads", () => { @@ -1037,14 +396,6 @@ describe("partitioning with the hosts' own priority reads", () => { expect(groups[0]!.entries.map((item) => item.number)).toEqual([3]); }); - it("appends an authored row the partition page missed to Others rather than moving it up", () => { - const shown = entry({ number: 1, updatedAt: "2026-07-06T00:00:00Z" }); - const olderMine = authoredRow(9, "2026-01-01T00:00:00Z"); - const groups = partitionPullRequestsWithPriority([shown, olderMine], [], []); - expect(groups).toHaveLength(1); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([1, 9]); - }); - it("gives authored precedence over review-requested and orders partitions by recency", () => { const both = authoredRow(1, "2026-07-01T00:00:00Z"); const requested = entry({ @@ -1062,13 +413,6 @@ describe("partitioning with the hosts' own priority reads", () => { expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); - - it("lets the feed's copy of a partitioned row replace the partition's", () => { - const stale = authoredRow(1, "2026-07-01T00:00:00Z"); - const fresh = { ...stale, title: "Retitled" }; - const groups = partitionPullRequestsWithPriority([fresh], [stale], []); - expect(groups[0]!.entries[0]!.title).toBe("Retitled"); - }); }); describe("the list snapshot across a reload", () => { @@ -1088,14 +432,6 @@ describe("the list snapshot across a reload", () => { nextCursors: { "pingdotgg/t3code": "cursor-1" }, } as never; - it("hydrates the retained rows so ghosts never replace them", () => { - const storage = makeStorage(); - writePullRequestListSnapshot(storage, "env-1", { scope: "env-1:open:all::", data }); - const snapshot = readPullRequestListSnapshot(storage, "env-1"); - expect(snapshot?.scope).toBe("env-1:open:all::"); - expect(snapshot?.data.entries.map((item) => item.number)).toEqual([1]); - }); - it("carries neither stale failures nor stale cursors", () => { const storage = makeStorage(); writePullRequestListSnapshot(storage, "env-1", { scope: "s", data }); @@ -1104,33 +440,6 @@ describe("the list snapshot across a reload", () => { expect(snapshot?.data.nextCursors).toEqual({}); }); - it("answers nothing for another environment", () => { - const storage = makeStorage(); - writePullRequestListSnapshot(storage, "env-1", { scope: "s", data }); - expect(readPullRequestListSnapshot(storage, "env-2")).toBeNull(); - }); - - it("rejects a snapshot whose rows do not decode as entries", () => { - const storage = makeStorage(); - storage.setItem( - "t3.pullRequests.list:env-1", - JSON.stringify({ scope: "s", data: { entries: [null] } }), - ); - expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull(); - storage.setItem( - "t3.pullRequests.list:env-1", - JSON.stringify({ scope: "s", data: { entries: [{ host: "github.com" }] } }), - ); - expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull(); - }); - - it("shrugs off corrupt storage and no storage at all", () => { - const storage = makeStorage(); - storage.setItem("t3.pullRequests.list:env-1", "{not json"); - expect(readPullRequestListSnapshot(storage, "env-1")).toBeNull(); - expect(readPullRequestListSnapshot(undefined, "env-1")).toBeNull(); - }); - it("caps the accumulated rows but keeps the whole host set", () => { const storage = makeStorage(); const manyEntries = Array.from({ length: 120 }, (_, index) => entry({ number: index + 1 })); @@ -1178,43 +487,7 @@ describe("remembered pull request list controls", () => { }; }; - it("restores every filter and the selected sort", () => { - const storage = makeStorage(); - const preferences = { - involvement: "reviewing", - state: "merged", - environmentId: "env-1" as EnvironmentId, - projectId: "project-1" as ProjectId, - host: "github.com", - q: "workflow", - draft: "hide", - review: "approved", - checks: "passing", - author: "octocat", - labels: ["bug", "priority"], - sort: "largest", - } as const; - - writePullRequestListPreferences(preferences, storage); - expect(readPullRequestListPreferences(storage)).toEqual(preferences); - }); - - it("omits the default sort from storage", () => { - expect( - pullRequestListPreferences({ - involvement: "all", - state: "open", - sort: "ready", - }), - ).toEqual({ involvement: "all", state: "open" }); - }); - - it("keeps an explicit recency sort", () => { - expect( - pullRequestListPreferences({ involvement: "all", state: "open", sort: "updated" }), - ).toEqual({ involvement: "all", state: "open", sort: "updated" }); - }); - + // regression: #9188 corrupt stored list controls broke the page it("falls back to the default controls when storage is corrupt", () => { const storage = makeStorage(); storage.setItem("t3.pullRequests.preferences", "{not json"); @@ -1263,10 +536,6 @@ describe("merging the environments' own listings", () => { ...overrides, }) as Parameters[0][number][1]; - it("answers nothing until an environment has", () => { - expect(mergePullRequestLists([])).toBeNull(); - }); - it("tags every row with the environment that read it, newest first", () => { const merged = mergePullRequestLists([ [ENV_1, answer({ entries: [entry({ number: 1, updatedAt: "2026-07-01T00:00:00Z" })] })], @@ -1278,13 +547,7 @@ describe("merging the environments' own listings", () => { ]); }); - it("tells two environments' copies of one pull request apart", () => { - const row = entry({ number: 4 }); - expect(pullRequestEntryKey({ ...row, environmentId: ENV_1 })).not.toBe( - pullRequestEntryKey({ ...row, environmentId: ENV_2 }), - ); - }); - + // regression: #6490 one environment's error showed on every environment it("keeps project errors scoped to the environment that reported them", () => { const error = { projectId: "project-1" as ProjectId, @@ -1329,22 +592,6 @@ describe("merging the environments' own listings", () => { }, ]); }); - - it("keeps each environment's continuation to itself", () => { - const merged = mergePullRequestLists([ - [ENV_1, answer({ nextCursors: { "github.com pingdotgg/t3code": "cursor-1" } })], - [ENV_2, answer()], - ]); - expect(merged?.nextCursors).toEqual({ - [ENV_1]: { "github.com pingdotgg/t3code": "cursor-1" }, - }); - }); - - it("reads the same key whichever order the environments connected in", () => { - expect(pullRequestEnvironmentSetKey(["env-2", "env-1"])).toBe( - pullRequestEnvironmentSetKey(["env-1", "env-2"]), - ); - }); }); describe('who "I" am, per server', () => { @@ -1375,29 +622,6 @@ describe('who "I" am, per server', () => { ), ).toEqual([1]); }); - - it("still reads a single server's host-keyed viewers, which is what a snapshot carries", () => { - expect( - filterPullRequestsByInvolvement( - [entry({ number: 1, author: byBilal })], - { "github.com": "Bilal" }, - "authored", - ).map((row) => row.number), - ).toEqual([1]); - }); - - it("names the servers with more rows and no cursor to reach them by", () => { - const merged = mergePullRequestLists([ - [ - ENV_1, - { ...answer({}, []), truncated: true, nextCursors: { "github.com acme/web": "cursor-1" } }, - ], - [ENV_2, { ...answer({}, []), truncated: true }], - ])!; - - expect(merged.truncatedEnvironments).toEqual([ENV_1, ENV_2]); - expect(Object.keys(merged.nextCursors)).toEqual([ENV_1]); - }); }); describe("the project an id names", () => { @@ -1407,43 +631,16 @@ describe("the project an id names", () => { { id: "project-2", environmentId: ENV_2 }, ]; - it("takes the server it was given", () => { - expect(findScopedProject(projects, ENV_2, "project-1")?.environmentId).toBe(ENV_2); - }); - it("answers a bare id only where one server has it", () => { expect(findScopedProject(projects, null, "project-2")?.environmentId).toBe(ENV_2); expect(findScopedProject(projects, null, "project-1")).toBeUndefined(); }); - - it("answers nothing for a server that does not have it", () => { - expect(findScopedProject(projects, ENV_1, "project-2")).toBeUndefined(); - }); }); describe("which environments a listing should ask", () => { const ENV_3 = "env-3" as EnvironmentId; const environmentIds = [ENV_1, ENV_2, ENV_3]; - it("asks only the owning server once the project is unambiguous", () => { - const projects = [{ id: "project-1", environmentId: ENV_2 }]; - expect( - resolveQueryEnvironmentIds( - environmentIds, - projects, - { environmentId: ENV_2 }, - "project-1", - true, - ), - ).toEqual([ENV_2]); - }); - - it("asks every environment when there is no project narrowing at all", () => { - expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, undefined, true)).toEqual( - environmentIds, - ); - }); - it("asks only the environments that actually hold an ambiguous bare id, never a third that doesn't", () => { const projects = [ { id: "project-1", environmentId: ENV_1 }, @@ -1453,26 +650,6 @@ describe("which environments a listing should ask", () => { resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-1", true), ).toEqual([ENV_1, ENV_2]); }); - - it("asks nothing for a bare id no environment holds", () => { - const projects = [{ id: "project-1", environmentId: ENV_1 }]; - expect( - resolveQueryEnvironmentIds(environmentIds, projects, undefined, "project-9", true), - ).toEqual([]); - }); - - it("asks every environment while the servers have not said what they hold yet", () => { - expect(resolveQueryEnvironmentIds(environmentIds, [], undefined, "project-1", false)).toEqual( - environmentIds, - ); - }); - - it("keeps the single-environment case unchanged", () => { - const projects = [{ id: "project-1", environmentId: ENV_1 }]; - expect(resolveQueryEnvironmentIds([ENV_1], projects, undefined, "project-1", true)).toEqual([ - ENV_1, - ]); - }); }); describe("the server a saved selection names", () => { @@ -1481,21 +658,6 @@ describe("the server a saved selection names", () => { it("resolves to nothing yet for a server that is known but not ready, rather than falling back", () => { expect(resolveSelectedEnvironmentId(ENV_2, known, ENV_1)).toBe(ENV_2); }); - - it("falls back for a server the workspace genuinely no longer has", () => { - const goneServer = "env-gone" as EnvironmentId; - expect(resolveSelectedEnvironmentId(goneServer, known, ENV_1)).toBe(ENV_1); - }); - - it("falls back to nothing when no server names a fallback either", () => { - const goneServer = "env-gone" as EnvironmentId; - expect(resolveSelectedEnvironmentId(goneServer, known, null)).toBeNull(); - }); - - it("uses the fallback outright when nothing was named", () => { - expect(resolveSelectedEnvironmentId(undefined, known, ENV_1)).toBe(ENV_1); - expect(resolveSelectedEnvironmentId(undefined, known, null)).toBeNull(); - }); }); describe("the priority groups against a paginated feed", () => { @@ -1534,22 +696,6 @@ describe("pull request list overrides", () => { }) as unknown as PullRequestListEntry; const key = (row: { number: number }) => `#${row.number}`; - it("maps the actions that change a row's state and nothing else", () => { - const now = new Date("2026-07-02T00:00:00Z"); - expect(pullRequestOverrideAfterAction(entry(1, "open"), "close", now, 7)).toEqual({ - state: "closed", - updatedAt: "2026-07-02T00:00:00.000Z", - token: 7, - at: now.getTime(), - }); - expect(pullRequestOverrideAfterAction(entry(1, "closed"), "reopen", now, 1)?.state).toBe( - "open", - ); - expect(pullRequestOverrideAfterAction(entry(1, "open"), "merge", now, 1)?.state).toBe("merged"); - expect(pullRequestOverrideAfterAction(entry(1, "open"), "draft", now, 1)?.isDraft).toBe(true); - expect(pullRequestOverrideAfterAction(entry(1, "open"), "update-branch", now, 1)).toBeNull(); - }); - it("writes the override over the row and drops it from a list whose state it left", () => { const rows = [entry(1, "open"), entry(2, "open")]; const overrides = new Map([ @@ -1566,18 +712,6 @@ describe("pull request list overrides", () => { expect(applyPullRequestOverrides(rows, new Map(), key, "open")).toBe(rows); }); - it.each([ - ["open", [1]], - ["closed", [2]], - ["merged", [3]], - ["all", [1, 2, 3]], - ] as const)("filters host rows by %s after pending actions settle", (state, expected) => { - const rows = [entry(1, "open"), entry(2, "closed"), entry(3, "merged")]; - expect(applyPullRequestOverrides(rows, new Map(), key, state).map((row) => row.number)).toEqual( - expected, - ); - }); - it("keeps a confirmed close hidden when its override is cleared", () => { const at = 1_000_000; const rows = [entry(1, "closed"), entry(2, "open"), entry(3, "merged")]; @@ -1590,17 +724,6 @@ describe("pull request list overrides", () => { 2, ]); }); - - it("hands back the held object for a row a refresh did not change", () => { - const previous = [entry(1, "open"), entry(2, "open")]; - const next = [{ ...entry(1, "open") }, { ...entry(2, "open"), state: "merged" as const }]; - const reused = reusePullRequestEntries(previous, next, key); - expect(reused[0]).toBe(previous[0]); - expect(reused[1]).toBe(next[1]); - expect( - reusePullRequestEntries(previous, [{ ...entry(1, "open") }, { ...entry(2, "open") }], key), - ).toBe(previous); - }); }); describe("pull request list override settlement", () => { @@ -1680,6 +803,7 @@ describe("refresh page size", () => { expect(largestPullRequestReadCount(twoPagesAcrossFourRepositories("gitlab"))).toBe(198); }); + // regression: #19403 refreshes were not bounded by each tracker read it("asks for the whole host where one search reads every repository", () => { expect(largestPullRequestReadCount(twoPagesAcrossFourRepositories("github"))).toBe(792); }); @@ -1758,37 +882,4 @@ describe("findProjectForRepository", () => { }), ).toBeUndefined(); }); - - it.each([ - ["gitlab", "t3tools/platform/t3code", "T3Tools/Platform/T3Code"], - ["github", "t3tools/t3code", "T3Tools/T3Code"], - ] as const)( - "selects the %s repository on the requested host despite a scope on another host", - (provider, repository, displayName) => { - const project = (host: string) => ({ - id: host, - environmentId: ENV_1, - repositoryIdentity: { - canonicalKey: `${host}/${repository}`, - locator: { source: "git-remote" as const, remoteName: "origin", remoteUrl: "" }, - provider, - displayName, - owner: "t3tools", - name: "t3code", - }, - }); - const publicProject = project(`${provider}.com`); - const enterpriseProject = project(`${provider}.acme.test`); - const projects = [publicProject, enterpriseProject]; - - expect( - findProjectForRepository(projects, { - repository: repository.toUpperCase(), - host: `${provider}.ACME.TEST`, - scopedProjectId: publicProject.id, - scopedEnvironmentId: ENV_1, - }), - ).toBe(enterpriseProject); - }, - ); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index fc2a49f876bf..56d1f8146f38 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -813,7 +813,7 @@ export function rankPullRequestsBlockedOnAuthor( +function rankPullRequestsBlockedOnReviewer( entries: ReadonlyArray, ): ReadonlyArray { return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1)); diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts new file mode 100644 index 000000000000..62a3200a6663 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -0,0 +1,127 @@ +import type { + PullRequestInvolvement, + PullRequestListFilters, + PullRequestListResult, +} from "@t3tools/contracts"; + +import type { PullRequestTabStatusSeed } from "~/components/RightPanelTabs"; +import { pullRequestSurfaceId } from "~/rightPanelStore"; +import { + filterPullRequestsByInvolvement, + groupPullRequestsByInvolvement, + matchesPullRequestFilters, + matchesPullRequestQuery, + partitionPullRequestsWithPriority, + pullRequestEntryKey, + pullRequestEntryViewer, + type EnvironmentPullRequestEntry, + type PullRequestPartitionsSnapshot, +} from "./pullRequestList.logic"; + +export const pullRequestListEntryId = (target: Parameters[0]) => + pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); + +/** The held rows narrowed to what the reader asked for, in the groups the list shows. */ +export function pullRequestGroups({ + known, + providers, + viewers, + involvement, + localFilters, + hasLocalFilters, + text, + answeredLocally, + baselineEmpty, + held, + authored, + reviewing, + partitionsWanted, +}: { + known: ReadonlyArray; + providers: PullRequestListResult["providers"]; + viewers: PullRequestListResult["viewers"]; + involvement: PullRequestInvolvement; + localFilters: PullRequestListFilters; + hasLocalFilters: boolean; + text: string; + answeredLocally: boolean; + baselineEmpty: boolean; + held: PullRequestPartitionsSnapshot | undefined; + authored: ReadonlyArray | undefined; + reviewing: ReadonlyArray | undefined; + partitionsWanted: boolean; +}) { + const searchingHosts = new Set( + providers.flatMap((provider) => (provider.searchesOnHost ? [provider.host] : [])), + ); + + const entries = (() => { + const involvementEntries = filterPullRequestsByInvolvement(known, viewers, involvement); + // The hosts search more than the row shows — a body, a review, a commit message — so once + // their answer is in, narrowing it again here would throw away matches the reader asked for. + // The local pass stands in for the answer that has not arrived yet, and for the hosts that + // answered without searching at all: Azure DevOps has no text filter, so its rows arrive + // whole and would otherwise sit under a search that never touched them. + const narrowedEntries = hasLocalFilters + ? involvementEntries.filter((entry) => + matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), + ) + : involvementEntries; + if (text.length === 0) { + return narrowedEntries.toSorted((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); + } + return narrowedEntries.filter( + (entry) => + (answeredLocally && searchingHosts.has(entry.host)) || matchesPullRequestQuery(entry, text), + ); + })(); + + const listedPullRequestTabStatuses: Record = Object.fromEntries( + entries.map((entry) => [ + pullRequestSurfaceId(entry), + { + state: entry.state, + isDraft: entry.isDraft, + }, + ]), + ); + + const groups = (() => { + if (involvement !== "all") return [{ key: "others" as const, label: "", entries }]; + if (baselineEmpty) { + return groupPullRequestsByInvolvement(entries, viewers); + } + const narrow = (rows: ReadonlyArray | undefined) => + rows === undefined || !hasLocalFilters + ? rows + : rows.filter((entry) => + matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), + ); + const narrowedAuthored = narrow(partitionsWanted ? (authored ?? held?.authored) : undefined); + const narrowedReviewing = narrow(partitionsWanted ? (reviewing ?? held?.reviewing) : undefined); + if (narrowedAuthored === undefined || narrowedReviewing === undefined) { + return groupPullRequestsByInvolvement(entries, viewers); + } + return partitionPullRequestsWithPriority(entries, narrowedAuthored, narrowedReviewing); + })(); + + const entriesByStatsKey: ReadonlyMap = new Map( + groups.flatMap((group) => + group.entries.map((entry) => [pullRequestEntryKey(entry), entry] as const), + ), + ); + const heldPullRequestsBySurface = new Map( + groups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), + ), + ); + return { + entries, + listedPullRequestTabStatuses, + groups, + entriesByStatsKey, + heldPullRequestsBySurface, + }; +} diff --git a/apps/web/src/components/pullRequest/pullRequestListOptions.ts b/apps/web/src/components/pullRequest/pullRequestListOptions.ts new file mode 100644 index 000000000000..1b56b1cc85c6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListOptions.ts @@ -0,0 +1,40 @@ +import type { PullRequestInvolvement, PullRequestListState } from "@t3tools/contracts"; +import { + CalendarArrowDownIcon, + CalendarArrowUpIcon, + ClockIcon, + EyeIcon, + LayersIcon, + ListChecksIcon, + Maximize2Icon, + Minimize2Icon, + PenLineIcon, + UserLockIcon, +} from "lucide-react"; + +import type { PullRequestFilterOption } from "./PullRequestListFilters"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; +import { PullRequestGlyph } from "./pullRequestIcons"; + +export const INVOLVEMENT_TABS = [ + { value: "all", label: "All", Icon: LayersIcon }, + { value: "reviewing", label: "Reviewing", Icon: EyeIcon }, + { value: "authored", label: "Authored", Icon: PenLineIcon }, +] as const satisfies ReadonlyArray>; + +export const STATE_TABS = [ + { value: "all", label: "All", Icon: LayersIcon }, + { value: "open", label: "Open", Icon: PullRequestGlyph.pullRequest }, + { value: "closed", label: "Closed", Icon: PullRequestGlyph.closed }, + { value: "merged", label: "Merged", Icon: PullRequestGlyph.merged }, +] as const satisfies ReadonlyArray>; + +export const SORT_OPTIONS = [ + { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, + { value: "blocked", label: "Blocked on me", Icon: UserLockIcon }, + { value: "updated", label: "Recently updated", Icon: ClockIcon }, + { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, + { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, + { value: "largest", label: "Largest shown", Icon: Maximize2Icon }, + { value: "smallest", label: "Smallest shown", Icon: Minimize2Icon }, +] as const satisfies ReadonlyArray>; diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts b/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts deleted file mode 100644 index be2efa94c207..000000000000 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolvePullRequestConflict, resolvePullRequestState } from "./pullRequestPresentation"; -import { PullRequestGlyph } from "./pullRequestIcons"; - -describe("resolvePullRequestState", () => { - it.each([ - [ - "open", - { state: "open", isDraft: false }, - PullRequestGlyph.pullRequest, - "Open", - "text-emerald-600 dark:text-emerald-300/90", - ], - [ - "draft", - { state: "open", isDraft: true }, - PullRequestGlyph.draft, - "Draft", - "text-zinc-500 dark:text-zinc-400/80", - ], - [ - "closed", - { state: "closed", isDraft: false }, - PullRequestGlyph.closed, - "Closed", - "text-red-600 dark:text-red-300/90", - ], - [ - "merged", - { state: "merged", isDraft: false }, - PullRequestGlyph.merged, - "Merged", - "text-violet-600 dark:text-violet-300/90", - ], - ] as const)( - "resolves the %s lifecycle presentation", - (_name, input, Icon, label, toneClassName) => { - expect(resolvePullRequestState(input)).toEqual({ - Icon, - label, - toneClassName, - }); - }, - ); - - it("keeps a merged pull request merged when stale draft metadata is also present", () => { - expect(resolvePullRequestState({ state: "merged", isDraft: true })).toMatchObject({ - Icon: PullRequestGlyph.merged, - label: "Merged", - }); - }); - - it("keeps a closed pull request closed when stale draft metadata is also present", () => { - expect(resolvePullRequestState({ state: "closed", isDraft: true })).toMatchObject({ - Icon: PullRequestGlyph.closed, - label: "Closed", - }); - }); - - it("keeps lifecycle and conflict presentation independent for an open conflicting pull request", () => { - const input = { - state: "open" as const, - isDraft: false, - mergeability: "conflicting" as const, - baseBranch: "main", - }; - - expect(resolvePullRequestState(input)).toMatchObject({ - Icon: PullRequestGlyph.pullRequest, - label: "Open", - }); - expect(resolvePullRequestConflict(input)).toMatchObject({ - Icon: PullRequestGlyph.conflicting, - label: "Conflicts with main", - toneClassName: "text-destructive", - }); - }); -}); - -describe("resolvePullRequestConflict", () => { - it.each([ - ["closed", { state: "closed", isDraft: false }], - ["merged", { state: "merged", isDraft: false }], - ["closed draft", { state: "closed", isDraft: true }], - ["merged draft", { state: "merged", isDraft: true }], - ["open draft", { state: "open", isDraft: true }], - ] as const)("does not report a conflict for %s", (_name, input) => { - expect( - resolvePullRequestConflict({ - ...input, - mergeability: "conflicting", - baseBranch: "main", - }), - ).toBeNull(); - }); - - it.each([ - ["omitted", undefined], - ["unknown", "unknown"], - ["mergeable", "mergeable"], - ] as const)("does not report an open conflict when mergeability is %s", (_name, mergeability) => { - const input = { - state: "open" as const, - isDraft: false, - ...(mergeability === undefined ? {} : { mergeability }), - }; - expect(resolvePullRequestConflict(input)).toBeNull(); - }); - - it("reports a known conflict with its base branch", () => { - expect( - resolvePullRequestConflict({ - state: "open", - isDraft: false, - mergeability: "conflicting", - baseBranch: "main", - }), - ).toEqual({ - Icon: PullRequestGlyph.conflicting, - label: "Conflicts with main", - toneClassName: "text-destructive", - }); - }); - - it("reports a known conflict without inventing a base branch", () => { - expect( - resolvePullRequestConflict({ - state: "open", - isDraft: false, - mergeability: "conflicting", - }), - ).toEqual({ - Icon: PullRequestGlyph.conflicting, - label: "Has conflicts", - toneClassName: "text-destructive", - }); - }); -}); diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 0999b52b61ed..228760b22b90 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -117,7 +117,7 @@ export interface PullRequestConflictPresentation { readonly Icon: PullRequestGlyphIcon; } -export function resolvePullRequestConflict(input: { +function resolvePullRequestConflict(input: { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability?: PullRequestMergeability; diff --git a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts index 71ddcbf66985..5af97fea67c2 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts @@ -50,18 +50,6 @@ describe("one server per repository", () => { expect(plain(assignment)).toEqual({ "env-2": ["a2"] }); }); - it("drops a server with nothing of its own", () => { - const assignment = assignProjectsToEnvironments( - [ - project("a1", "env-1", "github.com/acme/app"), - project("a2", "env-2", "github.com/acme/app"), - ], - envs("env-1", "env-2"), - "env-1" as EnvironmentId, - ); - expect(assignment.has("env-2" as EnvironmentId)).toBe(false); - }); - it("keeps every copy of a project that has no identity to compare", () => { const assignment = assignProjectsToEnvironments( [project("p1", "env-1"), project("p2", "env-2")], @@ -71,18 +59,6 @@ describe("one server per repository", () => { expect(plain(assignment)).toEqual({ "env-1": ["p1"], "env-2": ["p2"] }); }); - it("keeps a repository listed by the one server that holds it", () => { - const assignment = assignProjectsToEnvironments( - [ - project("a1", "env-1", "github.com/acme/app"), - project("b2", "env-2", "gitlab.com/acme/app"), - ], - envs("env-1", "env-2"), - "env-1" as EnvironmentId, - ); - expect(plain(assignment)).toEqual({ "env-1": ["a1"], "env-2": ["b2"] }); - }); - it("keeps a server's own worktrees of the repository it lists", () => { const assignment = assignProjectsToEnvironments( [ @@ -108,18 +84,6 @@ describe("one server per repository", () => { expect(plain(assignment)).toEqual({ "env-1": ["a1"] }); }); - it("ignores a project on a server that is not being read", () => { - const assignment = assignProjectsToEnvironments( - [ - project("a1", "env-1", "github.com/acme/app"), - project("a2", "env-2", "github.com/acme/app"), - ], - envs("env-2"), - "env-2" as EnvironmentId, - ); - expect(plain(assignment)).toEqual({ "env-2": ["a2"] }); - }); - it("answers the same whatever order the projects arrive in", () => { const projects = [ project("a2", "env-2", "github.com/acme/app"), @@ -171,60 +135,6 @@ describe("where a pull request can be acted on", () => { ]); }); - it("matches copies however the remote is cased", () => { - const pickable = resolvePickableEnvironments( - on("env-1", "a1"), - [ - project("a1", "env-1", "github.com/acme/app"), - project("a2", "env-2", "GitHub.com/ACME/app"), - ], - connected("env-1", "env-2"), - ); - expect(pickable.map((entry) => entry.environmentId)).toEqual(["env-1", "env-2"]); - }); - - it("offers nothing where one server holds the repository", () => { - expect( - resolvePickableEnvironments( - on("env-1", "a1"), - [ - project("a1", "env-1", "github.com/acme/app"), - project("c2", "env-2", "github.com/acme/site"), - ], - connected("env-1", "env-2"), - ), - ).toEqual([]); - }); - - it("offers nothing for a project with no identity to compare", () => { - expect( - resolvePickableEnvironments( - on("env-1", "p1"), - [project("p1", "env-1"), project("p2", "env-2")], - connected("env-1", "env-2"), - ), - ).toEqual([]); - }); - - it("offers nothing while the projects are still arriving", () => { - expect(resolvePickableEnvironments(on("env-1", "a1"), [], connected("env-1", "env-2"))).toEqual( - [], - ); - }); - - it("leaves out a server that is not connected", () => { - expect( - resolvePickableEnvironments( - on("env-1", "a1"), - [ - project("a1", "env-1", "github.com/acme/app"), - project("a2", "env-2", "github.com/acme/app"), - ], - connected("env-1"), - ), - ).toEqual([]); - }); - it("names one copy per server, so two worktrees are one choice", () => { const pickable = resolvePickableEnvironments( on("env-1", "a1"), diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts index 5c660118ed28..ec3319f01084 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -59,20 +59,6 @@ describe("pull request project filter choices", () => { expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); }); - it("matches canonical repositories regardless of casing", () => { - const main = project("main"); - const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); - - expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); - }); - - it("does not add a server suffix after duplicate checkouts have collapsed", () => { - const main = project("main"); - - expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); - expect(main.title).toBe("t3code"); - }); - it("distinguishes same-named repositories on one server by checkout path", () => { const projects = [ project("upstream"), @@ -87,6 +73,7 @@ describe("pull request project filter choices", () => { ]); }); + // regression: #9948 project filter deduplication must not merge across hosts it("keeps repositories on different hosts separate", () => { const choices = pullRequestFilterProjects( [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], @@ -96,6 +83,7 @@ describe("pull request project filter choices", () => { expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); }); + // regression: #9948 project filter deduplication must not merge unidentified repos it("does not merge projects whose repository identity is unknown", () => { const choices = pullRequestFilterProjects( [project("first", nucbox, null), project("second", nucbox, null)], @@ -123,36 +111,4 @@ describe("pull request project filter choices", () => { "t3code · nucbox-1 · /work/main · env-nucbox", ]); }); - - it("uses the environment id when its label is unavailable", () => { - const choices = pullRequestFilterProjects( - [project("main"), project("remote", cups)], - new Map(), - ); - - expect(choices.map((choice) => choice.title)).toEqual([ - "t3code · env-cups", - "t3code · env-nucbox", - ]); - }); - - it("can distinguish unresolved project records that also share a checkout path", () => { - const first = project("first", nucbox, null); - const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; - - const choices = pullRequestFilterProjects([first, second], labels); - - expect(choices.map((choice) => choice.title)).toEqual([ - "t3code · nucbox-1 · /work/first · env-nucbox · first", - "t3code · nucbox-1 · /work/first · env-nucbox · second", - ]); - }); - - it("leaves unrelated names unchanged and orders them alphabetically", () => { - const app = { ...project("app"), title: "Zebra" }; - const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; - - expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); - expect(pullRequestFilterProjects([], labels)).toEqual([]); - }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts index ed17c7b466fb..113c1029e7d1 100644 --- a/apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts @@ -1,10 +1,7 @@ import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - applyPendingPullRequestReactions, - pullRequestReactionTooltip, -} from "./pullRequestReactions.logic"; +import { applyPendingPullRequestReactions } from "./pullRequestReactions.logic"; function reaction(overrides: Partial = {}): PullRequestReaction { return { @@ -16,118 +13,11 @@ function reaction(overrides: Partial = {}): PullRequestReac }; } -describe("reaction tooltip", () => { - it("reads as GitHub's sentence for one, two and three names", () => { - expect( - pullRequestReactionTooltip(reaction({ content: "thumbs-up", count: 1, actors: ["Bil0000"] })), - ).toBe("Bil0000 reacted with thumbs up emoji"); - expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["Bil0000", "octocat"] }))).toBe( - "Bil0000 and octocat reacted with heart emoji", - ); - expect( - pullRequestReactionTooltip( - reaction({ - content: "eyes", - count: 3, - actors: ["Bil0000", "octocat"], - viewerHasReacted: true, - }), - ), - ).toBe("You, Bil0000, and octocat reacted with eyes emoji"); - }); - - it("counts everyone past the third name, including the ones the host never named", () => { - expect( - pullRequestReactionTooltip( - reaction({ - content: "rocket", - count: 15, - actors: ["a", "b", "c", "d"], - viewerHasReacted: true, - }), - ), - ).toBe("You, a, b, and 12 others reacted with rocket emoji"); - expect(pullRequestReactionTooltip(reaction({ count: 2, actors: ["octocat"] }))).toBe( - "octocat and 1 other reacted with heart emoji", - ); - expect(pullRequestReactionTooltip(reaction({ count: 4, actors: [] }))).toBe( - "4 people reacted with heart emoji", - ); - expect(pullRequestReactionTooltip(reaction({ count: 1, actors: [] }))).toBe( - "1 person reacted with heart emoji", - ); - }); - - it("names the viewer as You, ahead of the other people who reacted", () => { - expect( - pullRequestReactionTooltip( - reaction({ count: 3, actors: ["Bil0000", "octocat"], viewerHasReacted: true }), - ), - ).toBe("You, Bil0000, and octocat reacted with heart emoji"); - }); - - it("names nobody as You when the viewer has not reacted", () => { - expect( - pullRequestReactionTooltip( - reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: false }), - ), - ).toBe("Bil0000 and octocat reacted with heart emoji"); - }); - - it("names actors as given, and leaves off You, for a host with no room for the viewer", () => { - expect( - pullRequestReactionTooltip( - reaction({ count: 2, actors: ["Bil0000", "octocat"], viewerHasReacted: true }), - ), - ).toBe("Bil0000 and octocat reacted with heart emoji"); - expect( - pullRequestReactionTooltip( - reaction({ - count: 4, - actors: ["Bil0000", "octocat", "hubot", "zzz"], - viewerHasReacted: true, - }), - ), - ).toBe("Bil0000, octocat, hubot, and 1 other reacted with heart emoji"); - }); - - it("keeps naming the viewer You when the host does leave them room", () => { - expect( - pullRequestReactionTooltip( - reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true }), - ), - ).toBe("You and octocat reacted with heart emoji"); - }); -}); - -describe("reaction tooltip, with a reaction in flight", () => { - it("still says You after an optimistic react, even at full capacity", () => { - const applied = applyPendingPullRequestReactions( - [reaction({ count: 2, actors: ["a", "b"], viewerHasReacted: false })], - new Map([["heart", true] as const]), - ); - expect(pullRequestReactionTooltip(applied[0]!)).toBe("You, a, and b reacted with heart emoji"); - }); - - it("drops You after an optimistic un-react, without treating the host as non-compliant", () => { - const applied = applyPendingPullRequestReactions( - [reaction({ count: 2, actors: ["octocat"], viewerHasReacted: true })], - new Map([["heart", false] as const]), - ); - expect(pullRequestReactionTooltip(applied[0]!)).toBe("octocat reacted with heart emoji"); - }); -}); - describe("pending reactions", () => { const pending = ( entries: ReadonlyArray, ): ReadonlyMap => new Map(entries); - it("returns the host's list untouched while nothing is in flight", () => { - const reactions = [reaction()]; - expect(applyPendingPullRequestReactions(reactions, pending([]))).toBe(reactions); - }); - it("adds a reaction nobody had yet, in picker order", () => { const applied = applyPendingPullRequestReactions( [reaction({ content: "rocket", count: 2, actors: ["a", "b"] })], diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts index 8466903eb536..2ba71f34eb08 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts @@ -30,17 +30,6 @@ describe("pull request review drafts", () => { ]); }); - it("keeps summary bodies isolated by review key", () => { - const store = usePullRequestReviewStore.getState(); - store.setSummary("review-a", "Summary A"); - store.setSummary("review-b", "Summary B"); - store.clearSummary("review-a", "Summary A"); - - expect(usePullRequestReviewStore.getState().summaries).toEqual({ - "review-b": "Summary B", - }); - }); - it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => { const reference = { projectId: ProjectId.make("project-a"), diff --git a/apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.test.ts deleted file mode 100644 index 8733eed901de..000000000000 --- a/apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; - -describe("section collapse scroll anchoring", () => { - it("returns the section's natural offset when its heading is pinned", () => { - expect( - sectionCollapseAnchorScrollTop({ - scrollTop: 600, - viewportTop: 264, - sectionTop: -200, - headingTop: 264, - }), - ).toBe(136); - }); - - it("allows a fractional sticky-edge difference", () => { - expect( - sectionCollapseAnchorScrollTop({ - scrollTop: 500, - viewportTop: 264, - sectionTop: 100, - headingTop: 264.5, - }), - ).toBe(336); - }); - - it("leaves the scroll position alone before the heading becomes sticky", () => { - expect( - sectionCollapseAnchorScrollTop({ - scrollTop: 120, - viewportTop: 264, - sectionTop: 320, - headingTop: 320, - }), - ).toBeNull(); - }); - - it("does not produce a negative scroll offset", () => { - expect( - sectionCollapseAnchorScrollTop({ - scrollTop: 20, - viewportTop: 264, - sectionTop: 200, - headingTop: 264, - }), - ).toBe(0); - }); -}); diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts new file mode 100644 index 000000000000..a3228f6b64fa --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -0,0 +1,179 @@ +import { + AuthSourceControlWriteScope, + type EnvironmentId, + type PullRequestDetail, + type PullRequestListEntry, + type PullRequestRef, +} from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; + +import { useEnvironmentQuery } from "~/state/query"; +import { useEnvironmentScope } from "~/state/session"; +import { + newestPullRequestSummary, + pullRequestEnvironment, + pullRequestListEntryToSummary, + useSharedPullRequestSummary, +} from "~/state/pullRequests"; +import { + readPullRequestDetailSnapshot, + resolveDisplayedPullRequestDetail, + writePullRequestDetailSnapshot, +} from "./pullRequestDetail.logic"; +import { pullRequestChecksState } from "./pullRequestPresentation"; + +export function pullRequestRefKey(reference: PullRequestRef) { + return `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; +} + +function pullRequestDetailSummary(data: PullRequestDetail | null) { + return data === null ? null : { ...data, checksState: pullRequestChecksState(data.checks) }; +} + +/** + * Loads a pull request's detail and activity and merges them, with the snapshot cache, the shared + * summary and the list row, into the detail the panel shows. + */ +export function usePullRequestDetail( + environmentId: EnvironmentId, + reference: PullRequestRef, + listEntry: PullRequestListEntry | null, +) { + const scopeKey = `${environmentId}:${pullRequestRefKey(reference)}`; + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); + const detailQuery = useEnvironmentQuery( + pullRequestEnvironment.detail({ environmentId, input: reference }), + ); + const activityQuery = useEnvironmentQuery( + pullRequestEnvironment.activity({ environmentId, input: reference }), + ); + const [cachedDetail, setCachedDetail] = useState(() => + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + const [cachedDetailKey, setCachedDetailKey] = useState(scopeKey); + if (cachedDetailKey !== scopeKey) { + setCachedDetailKey(scopeKey); + setCachedDetail( + detailQuery.data ?? + readPullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + ), + ); + } else if (detailQuery.data !== null && detailQuery.data !== cachedDetail) { + setCachedDetail(detailQuery.data); + } + const saveDetailSnapshot = useEffectEvent((data: PullRequestDetail) => { + writePullRequestDetailSnapshot( + typeof window === "undefined" ? undefined : window.localStorage, + environmentId, + reference, + data, + ); + }); + useEffect(() => { + if (detailQuery.data !== null) saveDetailSnapshot(detailQuery.data); + }, [detailQuery.data]); + const detailSummary = pullRequestDetailSummary(detailQuery.data); + const observedSummary = useSharedPullRequestSummary( + environmentId, + reference, + detailSummary, + detailQuery.dataUpdatedAt, + ); + const activity = activityQuery.data; + const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ + live: detailQuery.data, + cached: cachedDetail, + reference, + }); + // Every hook runs above this line: a hook between the merge and the panel's plain readers of + // `detail` would cost it its cache scope. + const listSummary = listEntry === null ? null : pullRequestListEntryToSummary(listEntry); + // The list row is also published to the shared cache, but only after this commit's layout + // effects run, so it is compared directly rather than trusted to be there already. + const sharedSummary = newestPullRequestSummary( + resolvedCoreDetail, + newestPullRequestSummary(observedSummary, listSummary), + ); + const coreDetail = + resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail + ? resolvedCoreDetail + : { + ...resolvedCoreDetail, + title: sharedSummary.title, + state: sharedSummary.state, + headBranch: sharedSummary.headBranch, + baseBranch: sharedSummary.baseBranch, + updatedAt: sharedSummary.updatedAt, + author: sharedSummary.author ?? resolvedCoreDetail.author, + additions: sharedSummary.additions ?? resolvedCoreDetail.additions, + deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, + changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, + mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, + isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, + }; + const detail = + coreDetail === null + ? null + : { + ...coreDetail, + capabilities: canWriteSourceControl + ? coreDetail.capabilities + : { + ...coreDetail.capabilities, + reactions: false, + edit: { changeRequest: false, comment: false }, + }, + viewerPermissions: canWriteSourceControl + ? coreDetail.viewerPermissions + : { + ...coreDetail.viewerPermissions, + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + updateMethods: [], + labels: false, + }, + author: activity?.author ?? coreDetail.author, + reviewers: activity?.reviewers ?? coreDetail.reviewers, + comments: activity?.comments ?? [], + commentCount: Math.max( + activity?.commentCount ?? 0, + new Set([ + ...(activity?.comments.map((comment) => comment.id) ?? []), + ...(activity?.reviewThreads.flatMap((thread) => + thread.comments.map((comment) => comment.id), + ) ?? []), + ]).size, + ), + commentsTruncated: activity?.commentsTruncated ?? false, + reviewThreads: activity?.reviewThreads ?? [], + commits: activity?.commits ?? [], + reactions: activity?.reactions ?? [], + }; + return { + canWriteSourceControl, + detailQuery, + activityQuery, + activity, + sharedSummary, + coreDetail, + detail, + }; +} diff --git a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx deleted file mode 100644 index b967849e9521..000000000000 --- a/apps/web/src/components/pullRequest/usePullRequestFilesViewed.browser.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - EnvironmentId, - ProjectId, - type PullRequestFilesViewedResult, - type PullRequestRef, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/reactivity"; -import { act, StrictMode, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const { host, setFilesViewed, toastAdd } = vi.hoisted(() => ({ - host: { data: null as unknown, refresh: vi.fn() }, - setFilesViewed: vi.fn(), - toastAdd: vi.fn(), -})); - -vi.mock(import("~/state/query"), async (original) => ({ - ...(await original()), - useEnvironmentQuery: (() => ({ - data: host.data, - error: null, - isPending: false, - isSuccess: true, - refresh: host.refresh, - })) as never, -})); -vi.mock(import("~/state/use-atom-command"), async (original) => ({ - ...(await original()), - useAtomCommand: (() => setFilesViewed) as never, -})); -vi.mock(import("../ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: toastAdd } as never }; -}); - -import { - usePullRequestFilesViewed, - type PullRequestFilesViewedView, -} from "./usePullRequestFilesViewed"; - -const environmentId = EnvironmentId.make("pr-files-viewed-audit"); -const reference: PullRequestRef = { - projectId: ProjectId.make("project-a"), - repository: "acme/web", - number: 42, -}; - -function answer(state: "unviewed" | "viewed" | "dismissed"): PullRequestFilesViewedResult { - return { - files: [ - { path: "a.ts", state }, - { path: "b.ts", state: "unviewed" }, - ], - truncated: false, - }; -} - -let root: Root; -let current: PullRequestFilesViewedView; - -function Surface() { - const view = usePullRequestFilesViewed({ environmentId, reference, enabled: true }); - useEffect(() => { - current = view; - }); - return null; -} - -const view = () => current; -const render = () => - act(async () => - root.render( - - - , - ), - ); - -async function reads(state: "unviewed" | "viewed" | "dismissed") { - host.data = answer(state); - await render(); -} - -beforeEach(async () => { - vi.useFakeTimers(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - host.data = answer("unviewed"); - host.refresh.mockReset(); - toastAdd.mockReset(); - setFilesViewed.mockReset().mockResolvedValue(AsyncResult.success(undefined)); - root = createRoot(document.createElement("div")); - await render(); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -describe("a mark whose file was pushed to before the read that followed it", () => { - it("gives way to the host and shows the file as changed", async () => { - view().setViewed("a.ts", true); - await act(async () => vi.advanceTimersByTimeAsync(500)); - expect(setFilesViewed).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { ...reference, files: [{ path: "a.ts", viewed: true }] }, - }); - expect(host.refresh).toHaveBeenCalled(); - - await reads("dismissed"); - - expect(view().isViewed("a.ts")).toBe(false); - expect(view().isStale("a.ts")).toBe(true); - }); - - it("stays given way to on every later read, having nothing left to recover", async () => { - view().setViewed("a.ts", true); - await act(async () => vi.advanceTimersByTimeAsync(500)); - await reads("dismissed"); - - view().refresh(); - await reads("dismissed"); - - expect(view().isViewed("a.ts")).toBe(false); - expect(view().isStale("a.ts")).toBe(true); - }); -}); - -describe("a mark the host has not answered for yet", () => { - it("holds the press while the write is still out", async () => { - let land = (_result: unknown) => {}; - setFilesViewed.mockReturnValueOnce(new Promise((resolve) => (land = resolve))); - - view().setViewed("a.ts", true); - await act(async () => vi.advanceTimersByTimeAsync(500)); - - await reads("unviewed"); - expect(view().isViewed("a.ts")).toBe(true); - - await act(async () => land(AsyncResult.success(undefined))); - expect(view().isViewed("a.ts")).toBe(true); - }); - - it("holds a press made since the read that would otherwise answer for it", async () => { - view().setViewed("a.ts", true); - await act(async () => vi.advanceTimersByTimeAsync(500)); - - view().setViewed("a.ts", true); - await reads("dismissed"); - - expect(view().isViewed("a.ts")).toBe(true); - expect(view().isStale("a.ts")).toBe(false); - }); -}); - -describe("a failed batch that the host partly applied", () => { - it("refreshes the host and accepts the applied marks", async () => { - setFilesViewed.mockResolvedValueOnce( - AsyncResult.failure(Cause.fail(new Error("partial batch"))), - ); - host.refresh.mockImplementation(() => { - host.data = answer("viewed"); - root.render( - - - , - ); - }); - act(() => { - view().setViewed("a.ts", true); - view().setViewed("b.ts", true); - }); - await act(async () => vi.advanceTimersByTimeAsync(500)); - expect(view().isViewed("a.ts")).toBe(true); - expect(view().isViewed("b.ts")).toBe(false); - }); -}); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts new file mode 100644 index 000000000000..347a00b628b9 --- /dev/null +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -0,0 +1,320 @@ +import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; +import { + pullRequestHostOf, + type EnvironmentId, + type ProjectId, + type PullRequestInvolvement, + type PullRequestListFilters, + type PullRequestListResult, + type PullRequestListState, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { useState } from "react"; + +import { useAllEnvironmentShellsBootstrapped, useProjects } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; +import { useDebouncedValue } from "~/state/queries"; +import { + applyPullRequestOverrides, + findProjectForRepository, + findScopedProject, + matchesPullRequestFilters, + pullRequestDiffStatKey, + pullRequestEntryKey, + pullRequestEntryViewer, + pullRequestEnvironmentSetKey, + pullRequestHostActions, + resolveProjectScope, + resolveQueryEnvironmentIds, + resolveSelectedEnvironmentId, + sortPullRequestGroups, + withDiffStat, + type EnvironmentPullRequestEntry, + type MergedPullRequestList, + type PullRequestDiffStats, + type PullRequestGroup, + type PullRequestListOverride, +} from "./pullRequestList.logic"; +import type { PullRequestExpectedHost } from "./PullRequestListFilters"; +import type { PullRequestListPreferences, PullRequestListSort } from "./pullRequestListPreferences"; +import { assignProjectsToEnvironments } from "./pullRequestProjectAssignment.logic"; +import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; + +export interface PullRequestsSearch extends PullRequestListPreferences { + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + readonly host?: string; + readonly repository?: string; + readonly number?: number; + readonly selectedProjectId?: ProjectId; + readonly selectedHost?: string; + readonly selectedEnvironmentId?: EnvironmentId; +} + +const SEARCH_DEBOUNCE_MS = 250; + +/** Which servers and projects the page reads, from the URL and what this client knows. */ +export function usePullRequestScope(search: PullRequestsSearch) { + const { environments } = useEnvironments(); + const allProjects = useProjects(); + const projectsKnown = useAllEnvironmentShellsBootstrapped(); + const capableEnvironments = environments + .filter( + (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, + ) + .toSorted((left, right) => left.environmentId.localeCompare(right.environmentId)); + const scopedEnvironmentId = + capableEnvironments.find((environment) => environment.environmentId === search.environmentId) + ?.environmentId ?? null; + const knownEnvironmentIds = new Set(environments.map((environment) => environment.environmentId)); + const environmentIds = capableEnvironments + .filter( + (environment) => + scopedEnvironmentId === null || environment.environmentId === scopedEnvironmentId, + ) + .map((environment) => environment.environmentId); + const environmentKey = pullRequestEnvironmentSetKey(environmentIds); + const capabilityKnown = environments.some((environment) => environment.serverConfig !== null); + const pullRequestsSupported = environmentIds.length > 0; + const projects = allProjects.filter( + (project) => !project.isScratch && environmentIds.includes(project.environmentId), + ); + const environmentLabels = new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ); + const scopedProjectId = resolveProjectScope(search.projectId, projects, projectsKnown); + const scopedProject = findScopedProject(projects, scopedEnvironmentId, scopedProjectId); + const scopedProjects = pullRequestFilterProjects(projects, environmentLabels, scopedProject); + + const selectedHost = search.selectedHost ?? search.host; + // A named server still connecting is kept named rather than falling back: falling back while it + // is merely not ready yet would resolve the project against whatever other server has answered, + // which can be the wrong one where two servers share a project id. + const selectedEnvironmentId = resolveSelectedEnvironmentId( + search.selectedEnvironmentId, + knownEnvironmentIds, + scopedEnvironmentId, + ); + const projectForRepository = + search.repository === undefined + ? undefined + : findProjectForRepository(projects, { + repository: search.repository, + host: selectedHost, + selectedEnvironmentId, + scopedProjectId, + scopedEnvironmentId, + }); + const linkedProjectId = resolveProjectScope(search.selectedProjectId, projects, projectsKnown); + const selectedProject = + linkedProjectId === undefined && search.repository !== undefined + ? projectForRepository + : findScopedProject(projects, selectedEnvironmentId, linkedProjectId ?? scopedProjectId); + const queryEnvironmentIds = resolveQueryEnvironmentIds( + environmentIds, + projects, + scopedProject, + scopedProjectId, + projectsKnown, + ); + const environmentQueries = ((): ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectIds?: ReadonlyArray; + }> => { + const plain = queryEnvironmentIds.map((environmentId) => ({ environmentId })); + if (!projectsKnown || scopedProjectId !== undefined) return plain; + const assignment = assignProjectsToEnvironments( + projects, + queryEnvironmentIds, + queryEnvironmentIds[0], + ); + const totals = new Map(); + for (const project of projects) { + totals.set(project.environmentId, (totals.get(project.environmentId) ?? 0) + 1); + } + return queryEnvironmentIds.flatMap((environmentId) => { + const projectIds = assignment.get(environmentId); + if (projectIds === undefined) return []; + if (projectIds.length === (totals.get(environmentId) ?? 0)) return [{ environmentId }]; + return [{ environmentId, projectIds }]; + }); + })(); + const assignmentKey = environmentQueries + .map(({ environmentId, projectIds }) => `${environmentId}#${projectIds?.join("+") ?? "*"}`) + .join("|"); + const linkedSelection = + search.repository && search.number && selectedProject + ? { + environmentId: selectedProject.environmentId, + repository: search.repository, + number: search.number, + projectId: selectedProject.id, + ...(selectedHost ? { host: selectedHost } : {}), + } + : null; + const expectedHosts = (() => { + const byHost = new Map(); + for (const project of projects) { + const kind = project.repositoryIdentity?.provider as SourceControlProviderKind | undefined; + if (kind === undefined) continue; + const host = pullRequestHostOf(project.repositoryIdentity, kind); + if (!byHost.has(host)) byHost.set(host, { host, kind }); + } + return [...byHost.values()]; + })(); + + return { + capableEnvironments, + scopedEnvironmentId, + environmentKey, + capabilityKnown, + pullRequestsSupported, + projects, + environmentLabels, + scopedProjectId, + scopedProject, + scopedProjects, + selectedProject, + queryEnvironmentIds, + environmentQueries, + assignmentKey, + linkedSelection, + expectedHosts, + projectsKnown, + }; +} + +export function usePullRequestQueryFilters(search: PullRequestsSearch, typedQuery: string) { + const sentQuery = useDebouncedValue(typedQuery, SEARCH_DEBOUNCE_MS); + const typedParsed = parsePullRequestQuery(typedQuery); + const sentParsed = parsePullRequestQuery(sentQuery); + + const menuFilters = { + ...(search.draft ? { draft: search.draft } : {}), + ...(search.review ? { review: search.review } : {}), + ...(search.checks ? { checks: search.checks } : {}), + ...(search.author ? { author: search.author } : {}), + ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}), + }; + const menuFiltered = Object.keys(menuFilters).length > 0; + const filters = { ...menuFilters, ...sentParsed.filters }; + const hasFilters = Object.keys(filters).length > 0; + const localFilters = { ...menuFilters, ...typedParsed.filters }; + const hasLocalFilters = Object.keys(localFilters).length > 0; + return { + sentQuery, + querySettled: typedQuery === sentQuery, + typedParsed, + sentParsed, + menuFilters, + menuFiltered, + filters, + hasFilters, + localFilters, + hasLocalFilters, + }; +} + +/** + * The hosts the list has answered for (kept while a host filter narrows the answer), and the + * groups as shown with measured sizes, optimistic actions, and the reader's sort. + */ +export function usePullRequestDisplayGroups({ + answered, + host, + groups, + statsByRow, + overrides, + state, + localFilters, + hasLocalFilters, + viewers, + sort, + text, + involvement, +}: { + answered: MergedPullRequestList | null; + host: string | undefined; + groups: ReadonlyArray>; + statsByRow: PullRequestDiffStats; + overrides: ReadonlyMap; + state: PullRequestListState; + localFilters: PullRequestListFilters; + hasLocalFilters: boolean; + viewers: PullRequestListResult["viewers"]; + sort: PullRequestListSort; + text: string; + involvement: PullRequestInvolvement; +}) { + const [hostsState, setHostsState] = useState<{ + readonly source: MergedPullRequestList | null; + readonly host: string | undefined; + readonly hosts: PullRequestListResult["providers"]; + }>({ source: null, host: undefined, hosts: [] }); + if (answered !== null && (hostsState.source !== answered || hostsState.host !== host)) { + setHostsState({ + source: answered, + host, + hosts: + host === undefined || hostsState.hosts.length === 0 ? answered.providers : hostsState.hosts, + }); + } + const hosts = hostsState.hosts; + const displayGroups = (() => { + const enriched = groups.map((group) => { + const overridden = applyPullRequestOverrides( + group.entries.map((entry) => withDiffStat(entry, statsByRow)), + overrides, + pullRequestEntryKey, + state, + ); + return { + ...group, + entries: + hasLocalFilters && overrides.size > 0 + ? overridden.filter( + (entry) => + !overrides.has(pullRequestEntryKey(entry)) || + matchesPullRequestFilters( + entry, + localFilters, + pullRequestEntryViewer(entry, viewers), + ), + ) + : overridden, + }; + }); + return sortPullRequestGroups( + enriched, + sort, + text, + (entry) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), + involvement, + ); + })(); + const shownCount = displayGroups.reduce((count, group) => count + group.entries.length, 0); + const hostActionsOf = pullRequestHostActions(hosts); + const closeSweepRows = new Map( + displayGroups.flatMap((group) => + group.entries.map( + (entry) => [pullRequestEntryKey(entry), { entry, groupKey: group.key }] as const, + ), + ), + ); + const listedPullRequestsBySurface = new Map( + displayGroups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), + ), + ); + + return { + hosts, + displayGroups, + shownCount, + hostActionsOf, + closeSweepRows, + listedPullRequestsBySurface, + }; +} diff --git a/apps/web/src/components/quickMessage/QuickMessagePanel.tsx b/apps/web/src/components/quickMessage/QuickMessagePanel.tsx index 12298c50596d..cbe84e2273bb 100644 --- a/apps/web/src/components/quickMessage/QuickMessagePanel.tsx +++ b/apps/web/src/components/quickMessage/QuickMessagePanel.tsx @@ -79,7 +79,10 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "~/state/server"; import { useEnvironmentScope } from "~/state/session"; import { allReachableShellsLiveAtom } from "~/state/shell"; -import { ComposerFooterModeControls, composerRuntimeModeOptions } from "../chat/ChatComposer"; +import { + ComposerFooterModeControls, + composerRuntimeModeOptions, +} from "../chat/ComposerFooterControls"; import { ComposerAttachMenu, type ComposerAttachPicker, diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx deleted file mode 100644 index a9eefdaa5823..000000000000 --- a/apps/web/src/components/search/ProjectContentSearchDialog.browser.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { AuthFilesystemReadScope, EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - readScope: vi.fn(), - // The real query hands back the same result array until the search changes. - matches: [ - { - resultIndex: 0, - path: "src/index.ts", - lineNumber: 3, - lineContent: "match", - matchRanges: [], - }, - ], - noMatches: [], - access: { canReadFiles: true, isCheckingAccess: false }, - listeners: new Set<() => void>(), -})); - -// The real query is subscription-backed, so access changes reach the dialog without a parent render. -function setAccess(access: { canReadFiles: boolean; isCheckingAccess: boolean }) { - state.access = access; - for (const listener of state.listeners) listener(); -} - -const target = { - environmentId: EnvironmentId.make("content-search-secondary"), - cwd: "/project", - projectName: "Project", - threadRef: scopeThreadRef( - EnvironmentId.make("content-search-secondary"), - ThreadId.make("content-search-thread"), - ), -}; - -vi.mock(import("~/hooks/useActiveProjectTarget"), async (original) => ({ - ...(await original()), - useActiveProjectTarget: () => target, -})); -vi.mock(import("~/state/session"), async (original) => ({ - ...(await original()), - readEnvironmentScope: state.readScope, -})); -vi.mock(import("~/state/queries"), async (original) => { - const { useSyncExternalStore } = await import("react"); - const subscribe = (listener: () => void) => { - state.listeners.add(listener); - return () => state.listeners.delete(listener); - }; - return { - ...(await original()), - useProjectContentSearch: (({ query }: { query: string }) => { - const { canReadFiles, isCheckingAccess } = useSyncExternalStore( - subscribe, - () => state.access, - ); - return { - canReadFiles, - isCheckingAccess, - error: null, - isPending: isCheckingAccess, - hasQuery: query.length > 0, - truncated: false, - invalidRegex: false, - matches: canReadFiles && query.length > 0 ? state.matches : state.noMatches, - }; - }) as never, - }; -}); - -import { useRightPanelStore } from "~/rightPanelStore"; -import { ProjectContentSearchDialog } from "./ProjectContentSearchDialog"; - -let root: Root; -let container: HTMLDivElement; -const onOpenChange = vi.fn(); -const openFile = vi.fn(); - -beforeEach(() => { - state.access = { canReadFiles: true, isCheckingAccess: false }; - state.listeners.clear(); - state.readScope.mockReset().mockReturnValue(true); - openFile.mockClear(); - onOpenChange.mockClear(); - vi.spyOn(useRightPanelStore.getState(), "openFile").mockImplementation(openFile); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -const input = () => container.querySelector("input")!; -const result = () => container.querySelector("[data-content-search-result]"); - -async function openSearch() { - await act(() => root.render()); -} - -async function type(text: string) { - await act(async () => { - const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - setValue.call(input(), text); - input().dispatchEvent(new Event("input", { bubbles: true })); - }); -} - -it("enables search after access resolves and opens a result in its own environment", async () => { - state.access = { canReadFiles: false, isCheckingAccess: true }; - await openSearch(); - expect(input().disabled).toBe(true); - expect(container.textContent).toContain("Checking file access…"); - - await act(() => setAccess({ canReadFiles: true, isCheckingAccess: false })); - expect(input().disabled).toBe(false); - await type("match"); - await act(async () => result()!.click()); - - expect(state.readScope).toHaveBeenCalledWith(target.environmentId, AuthFilesystemReadScope); - expect(openFile).toHaveBeenCalledWith(target.threadRef, "src/index.ts", 3); - expect(onOpenChange).toHaveBeenCalledWith(false); -}); - -it.each(["pointer", "keyboard"])( - "rechecks access before a retained %s action opens a result", - async (action) => { - await openSearch(); - await type("match"); - - state.readScope.mockReturnValue(false); - await act(async () => { - if (action === "pointer") result()!.click(); - else - input().dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), - ); - }); - - expect(state.readScope).toHaveBeenCalled(); - expect(openFile).not.toHaveBeenCalled(); - expect(onOpenChange).not.toHaveBeenCalled(); - }, -); diff --git a/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx b/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx deleted file mode 100644 index 51844a282363..000000000000 --- a/apps/web/src/components/settings/AcpRegistryIcon.browser.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import { AcpRegistryAgentIcon } from "./AcpRegistryIcon"; - -const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; -const fetchIcon = vi.fn(); -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("caches", undefined); - vi.stubGlobal("fetch", fetchIcon); - fetchIcon.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const image = () => container.querySelector("img"); -const fallback = () => container.querySelector('[data-slot="acp-icon-fallback"]'); - -it("falls back to the raw allowlisted CDN URL when the validating fetch is blocked", async () => { - // The official CDN serves no CORS headers, so the fetch can reject even - // though native loading works. - fetchIcon.mockRejectedValue(new TypeError("Failed to fetch")); - await act(async () => root.render()); - - await vi.waitFor(() => expect(image()?.getAttribute("src")).toBe(url)); - expect(image()?.referrerPolicy).toBe("no-referrer"); - expect(fallback()).not.toBeNull(); -}); - -function serve(body: string) { - fetchIcon.mockResolvedValue( - new Response(body, { status: 200, headers: { "content-type": "image/svg+xml" } }), - ); -} - -it("swaps the fallback for the validated blob once the image loads", async () => { - serve(''); - await act(async () => root.render()); - - await vi.waitFor(() => expect(fallback()).toBeNull()); - expect(image()?.src).toMatch(/^blob:/); - expect(image()?.checkVisibility()).toBe(true); -}); - -it("keeps the fallback and drops the image when the validated blob fails to decode", async () => { - serve("not an svg"); - await act(async () => root.render()); - - await vi.waitFor(() => expect(fetchIcon).toHaveBeenCalledOnce()); - await vi.waitFor(() => expect(image()).toBeNull()); - expect(fallback()).not.toBeNull(); -}); - -it("never renders an icon from outside the official CDN", async () => { - await act(async () => - root.render(), - ); - expect(fetchIcon).not.toHaveBeenCalled(); - expect(image()).toBeNull(); - expect(fallback()).not.toBeNull(); -}); diff --git a/apps/web/src/components/settings/AcpRegistryIcon.test.ts b/apps/web/src/components/settings/AcpRegistryIcon.test.ts index ccac3bcc37bf..a85a49d11dbd 100644 --- a/apps/web/src/components/settings/AcpRegistryIcon.test.ts +++ b/apps/web/src/components/settings/AcpRegistryIcon.test.ts @@ -61,30 +61,6 @@ describe("ACP Registry icon cache", () => { expect(put).not.toHaveBeenCalled(); }); - it("falls back to the network when CacheStorage cannot be opened", async () => { - const url = "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg"; - vi.stubGlobal("caches", { - open: vi.fn(async () => { - throw new Error("CacheStorage unavailable"); - }), - }); - fetchIcon.mockResolvedValue( - new Response("", { - status: 200, - headers: { "content-type": "image/svg+xml" }, - }), - ); - - await expect(loadCachedAcpRegistryIcon(url)).resolves.toMatchObject({ - type: "image/svg+xml", - }); - expect(fetchIcon).toHaveBeenCalledWith(url, { - credentials: "omit", - redirect: "error", - referrerPolicy: "no-referrer", - }); - }); - it("accepts only credential-free HTTPS URLs on the official CDN", () => { expect( resolveOfficialAcpRegistryIconUrl( diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx index 7814e3ce0d81..dd3b74027c63 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.browser.test.tsx @@ -1,14 +1,8 @@ -import { - EnvironmentId, - ProviderDriverKind, - type AcpRegistrySearchAgent, - type AcpRegistrySearchInput, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import { EnvironmentId, ProviderDriverKind, type AcpRegistrySearchAgent } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ result: null as { readonly agents: ReadonlyArray } | null, @@ -69,38 +63,29 @@ const gemini: AcpRegistrySearchAgent = { integrity: "registry", }; -const roots: Root[] = []; -let root: Root; - -function render(options?: { +function step(options?: { readonly configured?: boolean; readonly onPrepared?: (agent: AcpRegistrySearchAgent) => void; }) { - return act(async () => - root.render( - , - ), + return ( + ); } -const searchBox = () => page.getByRole("searchbox", { name: "Search ACP Registry" }); -const lastQuery = () => - (state.search.mock.lastCall?.[0] as { input: { query: string } } | undefined)?.input.query; - function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { @@ -109,7 +94,7 @@ function deferred() { return { promise, resolve }; } -beforeEach(() => { +function setup() { state.canManage = true; state.result = null; state.error = null; @@ -120,132 +105,13 @@ beforeEach(() => { _tag: "Success", value: { agentId: "gemini", version: "1.2.3", distribution: "npx", prepared: true }, }); - const container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - roots.push(root); -}); - -afterEach(async () => { - vi.useRealTimers(); - for (const mounted of roots.splice(0)) await act(async () => mounted.unmount()); - document.body.replaceChildren(); -}); - -it("shows the full catalog initially and after clearing a debounced search", async () => { - const agents = Array.from({ length: 25 }, (_, index) => ({ - ...gemini, - id: `gemini-${index}`, - name: `Gemini CLI ${index + 1}`, - })); - state.search.mockImplementation(({ input }: { input: AcpRegistrySearchInput }) => { - state.result = { - agents: agents - .filter((agent) => agent.name.toLowerCase().includes(input.query.toLowerCase())) - .slice(0, input.limit ?? 20), - }; - }); - const addButtons = () => page.getByRole("button", { name: /^Add Gemini CLI / }).elements(); - await render(); - expect(addButtons()).toHaveLength(25); - await expect.element(page.getByRole("button", { name: "Add Gemini CLI 25" })).toBeEnabled(); - - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - await userEvent.fill(searchBox(), " Gemini "); - await act(async () => vi.advanceTimersByTime(200)); - await userEvent.fill(searchBox(), " Gemini CLI "); - await act(async () => vi.advanceTimersByTime(299)); - expect(addButtons()).toHaveLength(25); - await act(async () => vi.advanceTimersByTime(1)); - expect(addButtons()).toHaveLength(20); - - await userEvent.clear(searchBox()); - await act(async () => vi.advanceTimersByTime(300)); - expect(addButtons()).toHaveLength(25); - await expect.element(page.getByRole("button", { name: "Add Gemini CLI 25" })).toBeEnabled(); -}); - -it("shows the loading, error, and empty states", async () => { - state.isPending = true; - await render(); - await expect.element(page.getByText("Searching the registry...")).toBeVisible(); - - state.isPending = false; - state.error = "Registry unavailable."; - await render(); - await expect.element(page.getByText("Registry unavailable.")).toBeVisible(); - - state.error = null; - state.result = { agents: [] }; - await render(); - await expect.element(page.getByText("No compatible agents found")).toBeVisible(); -}); - -it("submits a typed query at once and refreshes when the same query is submitted again", async () => { - await render(); - // A slow runner must not let the typing debounce submit the query before Enter does. - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - await userEvent.fill(searchBox(), "Codex"); - await userEvent.keyboard("{Enter}"); - expect(lastQuery()).toBe("Codex"); - expect(state.refresh).not.toHaveBeenCalled(); - - state.result = { agents: [gemini] }; - await render(); - await userEvent.click(searchBox()); - await userEvent.keyboard("{Enter}"); - expect(state.refresh).toHaveBeenCalledOnce(); - - state.isPending = true; - await render(); - await expect - .element(page.getByRole("status").filter({ hasText: "Refreshing ACP Registry results." })) - .toBeInTheDocument(); - await expect.element(page.getByRole("button", { name: "Add Gemini CLI" })).toBeVisible(); -}); - -it("prepares a result before handing it back to the wizard", async () => { - state.prepare.mockResolvedValueOnce({ - _tag: "Success", - value: { agentId: "gemini", version: "2.0.0", distribution: "binary", prepared: true }, - }); - state.result = { agents: [gemini] }; - const onPrepared = vi.fn(); - await render({ onPrepared }); - - await page.getByRole("button", { name: "Add Gemini CLI" }).click(); - - expect(state.prepare).toHaveBeenCalledWith({ environmentId, input: { agentId: "gemini" } }); - await vi.waitFor(() => - expect(onPrepared).toHaveBeenCalledWith({ - ...gemini, - version: "2.0.0", - distribution: "binary", - }), - ); -}); - -it("shows progress, locks the step while an agent prepares, and reports a failure", async () => { - const pending = deferred(); - state.prepare.mockReturnValueOnce(pending.promise); - state.result = { agents: [{ ...gemini, distribution: "binary" }] }; - await render(); - - await page.getByRole("button", { name: "Add Gemini CLI" }).click(); - await expect.element(page.getByRole("button", { name: "Downloading Gemini CLI" })).toBeDisabled(); - await expect.element(searchBox()).toBeDisabled(); - - await act(async () => - pending.resolve({ _tag: "Failure", cause: Cause.fail(new Error("Registry download failed.")) }), - ); - await expect.element(page.getByText("Registry download failed.")).toBeVisible(); - await expect.element(page.getByRole("button", { name: "Add Gemini CLI" })).toBeEnabled(); -}); +} it("rechecks the grant before preparing an agent", async () => { + setup(); state.result = { agents: [gemini] }; const onPrepared = vi.fn(); - await render({ onPrepared }); + await using _screen = await render(step({ onPrepared })); state.canManage = false; await page.getByRole("button", { name: "Add Gemini CLI" }).click(); @@ -255,14 +121,15 @@ it("rechecks the grant before preparing an agent", async () => { }); it("ignores prepare completion after unmount", async () => { + setup(); const pending = deferred(); state.prepare.mockReturnValueOnce(pending.promise); state.result = { agents: [gemini] }; const onPrepared = vi.fn(); - await render({ onPrepared }); + await using screen = await render(step({ onPrepared })); await page.getByRole("button", { name: "Add Gemini CLI" }).click(); - await act(async () => root.unmount()); + await screen.unmount(); await act(async () => pending.resolve({ _tag: "Success", @@ -272,11 +139,3 @@ it("ignores prepare completion after unmount", async () => { expect(onPrepared).not.toHaveBeenCalled(); }); - -it("shows an existing registry configuration as already added", async () => { - state.result = { agents: [gemini] }; - await render({ configured: true }); - await expect - .element(page.getByRole("button", { name: "Already added Gemini CLI" })) - .toBeDisabled(); -}); diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx index 9d4995be732d..dbca30bbdeb4 100644 --- a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx +++ b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx @@ -6,9 +6,9 @@ import { ThreadId, type ServerProvider, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const commands = vi.hoisted(() => ({ list: vi.fn(), @@ -77,41 +77,30 @@ const provider = { skills: [], } satisfies ServerProvider; -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -let root: Root; -let container: HTMLDivElement; - -function render(readOnly = false) { - return act(async () => - root.render( - , - ), +async function renderSection(readOnly = false) { + const screen = await render( + , ); + const button = (label: string) => { + const found = [...screen.container.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + expect(found, `button ${label}`).toBeDefined(); + return found!; + }; + return Object.assign(screen, { + button, + click: (label: string) => act(async () => button(label).click()), + }); } -function button(label: string) { - const found = [...container.querySelectorAll("button")].find( - (element) => element.textContent === label, - ); - expect(found, `button ${label}`).toBeDefined(); - return found!; -} -const click = (label: string) => act(async () => button(label).click()); -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { grants.scopes = new Set(["orchestration:read", "orchestration:operate", "providers:manage"]); commands.list.mockReset().mockResolvedValue({ _tag: "Success", @@ -157,138 +146,26 @@ beforeEach(() => { value: { loggedOut: true }, }); dialogs.confirm.mockReset().mockResolvedValue(true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -describe("AcpSessionManagementSection", () => { - it("lists and imports native sessions through the owning environment", async () => { - await render(); - await click("List sessions"); - - expect(commands.list).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, projectId }, - }); - expect(container.textContent).toContain(session.title); - - await click("Import"); - - expect(commands.import).toHaveBeenCalledWith({ - environmentId, - input: { - instanceId, - projectId, - sessionId: session.sessionId, - title: session.title, - updatedAt: session.updatedAt, - }, - }); - expect(button("Imported").disabled).toBe(true); - }); - - it("logs out the provider instance through the owning environment", async () => { - await render(); - await click("Log out"); - - expect(commands.logout).toHaveBeenCalledWith({ - environmentId, - input: { instanceId }, - }); - }); - - it("deletes unimported native sessions after destructive confirmation", async () => { - await render(); - await click("List sessions"); - await click("Delete"); - - expect(dialogs.confirm).toHaveBeenCalledOnce(); - expect(commands.delete).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, projectId, sessionId: session.sessionId }, - }); - }); - - it("locks the project picker while a project-scoped request is pending", async () => { - const pending = deferred<{ - readonly _tag: "Success"; - readonly value: { readonly providers: [] }; - }>(); - commands.listProviders.mockReturnValueOnce(pending.promise); - await render(); - const picker = () => - container.querySelector('[aria-label="Project for ACP sessions"]')!; - expect(picker().hasAttribute("data-disabled")).toBe(false); - - await click("List providers"); - - expect(picker().hasAttribute("data-disabled")).toBe(true); - await act(async () => pending.resolve({ _tag: "Success", value: { providers: [] } })); - expect(picker().hasAttribute("data-disabled")).toBe(false); - }); - - it("lists, saves, and disables configurable ACP providers", async () => { - await render(); - await click("List providers"); - - expect(commands.listProviders).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, projectId }, - }); - expect( - container.querySelector('input[aria-label="google base URL"]')?.value, - ).toBe("https://api.example.test/v1"); - await click("Save"); - expect(commands.setProvider).toHaveBeenCalledWith({ - environmentId, - input: { - instanceId, - projectId, - providerId: "google", - apiType: "openai", - baseUrl: "https://api.example.test/v1", - }, - }); - - await click("Disable"); - expect(commands.disableProvider).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, projectId, providerId: "google" }, - }); - }); -}); - -it("imports with task access even when provider configuration is read-only", async () => { - grants.scopes.delete("providers:manage"); - await render(true); - await click("List sessions"); - await click("Import"); - expect(commands.import).toHaveBeenCalledOnce(); -}); +} it("does not import with provider management alone", async () => { + setup(); grants.scopes.delete("orchestration:operate"); - await render(); - await click("List sessions"); - expect(button("Import").disabled).toBe(true); - await click("Import"); + await using screen = await renderSection(); + await screen.click("List sessions"); + expect(screen.button("Import").disabled).toBe(true); + await screen.click("Import"); expect(commands.import).not.toHaveBeenCalled(); }); it("rechecks task access after confirming deletion", async () => { - await render(); - await click("List sessions"); + setup(); + await using screen = await renderSection(); + await screen.click("List sessions"); dialogs.confirm.mockImplementationOnce(async () => { grants.scopes.delete("orchestration:operate"); return true; }); - await click("Delete"); + await screen.click("Delete"); expect(commands.delete).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx index 6b3de0508ebe..a6f9618a8c6f 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.browser.test.tsx @@ -1,4 +1,3 @@ -import { RegistryContext } from "@effect/atom-react"; import { EnvironmentId, ProviderDriverKind, @@ -6,10 +5,10 @@ import { type ServerProvider, } from "@t3tools/contracts"; import type { Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const actions = vi.hoisted(() => ({ toast: vi.fn(), @@ -144,22 +143,14 @@ function deferred() { return { promise, resolve }; } -let root: Root; - -function render(onOpenChange = actions.onOpenChange) { - return act(async () => - root.render( - - - , - ), - ); -} +const dialog = () => ( + +); function button(label: string) { const found = [...document.querySelectorAll("button")].find( (element) => element.textContent?.trim() === label, @@ -174,21 +165,32 @@ const input = (selector: string) => { return found!; }; async function type(selector: string, value: string) { - await act(async () => userEvent.fill(input(selector), value)); + await userEvent.fill(input(selector), value); await act(async () => input(selector).blur()); } const signInStep = () => document.querySelector('[aria-label="Sign-in step"]'); async function prepareCodexInstance() { - await render(); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await render(dialog())); await click("Configure manually"); await type("#add-provider-label", "Work"); await click("Next"); + stack.move(); + return screen; +} + +function setup() { + actions.canManageProviders = true; + actions.toast.mockReset(); + actions.onOpenChange.mockReset(); + settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); + settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); + settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); } describe("AddProviderInstanceDialog environment routing", () => { beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); appAtomRegistry.set(server.providers, []); server.refreshProviders.mockReset(); actions.canManageProviders = true; @@ -197,15 +199,10 @@ describe("AddProviderInstanceDialog environment routing", () => { settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); - root = createRoot(document.createElement("div")); - }); - afterEach(async () => { - await act(async () => root.unmount()); - vi.unstubAllGlobals(); }); it("creates a provider with its default identity without typing", async () => { - await render(); + await using _screen = await render(dialog()); const grok = [...document.querySelectorAll('[role="radio"]')].find((radio) => radio.textContent?.includes("Grok"), ) as HTMLElement; @@ -221,10 +218,11 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("chooses an unused identity for another account without replacing configured instances", async () => { + setup(); settingsHooks.read.mockReturnValue({ providerInstances: { codex_2: { driver: "codex", enabled: false } }, }); - await render(); + await using _screen = await render(dialog()); await click("Configure manually"); await click("Next"); await click("Add instance"); @@ -240,17 +238,11 @@ describe("AddProviderInstanceDialog environment routing", () => { }); }); - it("reads and writes settings through the supplied environment", async () => { - await render(); - - expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); - expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); - }); - it("awaits an atomic create before closing and retains prepared ACP metadata", async () => { + setup(); const mutation = deferred<{ readonly _tag: "Success"; readonly value: unknown }>(); settingsHooks.mutate.mockReturnValueOnce(mutation.promise); - await render(); + await using _screen = await render(dialog()); await click("Use Kilo Code"); expect(document.body.textContent).toContain("v4.2.0 · binary"); @@ -279,26 +271,9 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(actions.onOpenChange).toHaveBeenCalledWith(false); }); - it("configures a manually entered registry agent from the first provider screen", async () => { - await render(); - await click("Enter registry agent"); - await type("#add-provider-acpRegistry-manual-agentId", "devin"); - await click("Next"); - await click("Continue to sign-in"); - expect(settingsHooks.mutate).toHaveBeenCalledWith({ - operation: "create", - instanceId: "acpRegistry_custom", - instance: { - driver: "acpRegistry", - enabled: true, - config: { agentId: "devin" }, - }, - }); - expect(signInStep()).not.toBeNull(); - }); - it("creates a local command in the selected environment without a sign-in step", async () => { - await render(); + setup(); + await using _screen = await render(dialog()); await click("Use local command"); await click("Next"); expect(document.body.textContent).toContain("Executable is required."); @@ -344,7 +319,7 @@ describe("AddProviderInstanceDialog environment routing", () => { it("keeps the dialog open when the atomic upsert fails", async () => { settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); - await render(); + await using _screen = await render(dialog()); await click("Use Kilo Code"); await click("Continue to sign-in"); @@ -406,7 +381,8 @@ describe("AddProviderInstanceDialog environment routing", () => { }); it("rejects a queued save after the provider grant is revoked", async () => { - await prepareCodexInstance(); + setup(); + await using screen = await prepareCodexInstance(); const save = button("Add instance"); actions.canManageProviders = false; // The rendered button is still enabled; the click must recheck the live grant. @@ -415,18 +391,18 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(settingsHooks.mutate).not.toHaveBeenCalled(); expect(actions.toast).not.toHaveBeenCalled(); expect(actions.onOpenChange).not.toHaveBeenCalled(); - await render(); + await screen.rerender(dialog()); expect(button("Add instance").disabled).toBe(true); }); it("keeps a denied draft available when the provider grant arrives", async () => { actions.canManageProviders = false; - await prepareCodexInstance(); + await using screen = await prepareCodexInstance(); expect(button("Add instance").disabled).toBe(true); expect(settingsHooks.mutate).not.toHaveBeenCalled(); actions.canManageProviders = true; - await render(); + await screen.rerender(dialog()); expect(button("Add instance").disabled).toBe(false); await click("Add instance"); expect(settingsHooks.mutate).toHaveBeenCalledOnce(); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index f8db0b3f3270..180bffde7e5a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveAvailableInstanceId, getProviderIdentityDraft, - isConfiguredAcpRegistryAgent, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, updateProviderIdentityDraft, @@ -13,10 +12,7 @@ describe("resolveWizardNavigation", () => { const invalidId = { instanceIdError: "Instance ID is required." }; const validId = { instanceIdError: null }; - it("allows moving from Driver to Identity before the instance id is valid", () => { - expect(resolveWizardNavigation(0, 1, 3, invalidId)).toEqual({ kind: "navigate", step: 1 }); - }); - + // regression: #2813 the wizard advanced past an invalid instance id it("blocks Next from Identity to Config while the instance id is invalid", () => { expect(resolveWizardNavigation(1, 2, 3, invalidId)).toEqual({ kind: "blocked", @@ -101,16 +97,6 @@ describe("ACP Registry wizard", () => { ); }); - it("only marks matching ACP Registry instances as already added", () => { - const instances = { - codex: { driver: "codex", config: { agentId: "gemini" } }, - registry: { driver: "acpRegistry", config: { agentId: "gemini" } }, - }; - - expect(isConfiguredAcpRegistryAgent(instances, "gemini")).toBe(true); - expect(isConfiguredAcpRegistryAgent(instances, "codex")).toBe(false); - }); - it("keeps registry-prefilled identity separate from other drivers", () => { const registryDrafts = updateProviderIdentityDraft({}, "acpRegistry", { label: "Gemini CLI", diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index ce86600517cd..b04bccd7e23a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; diff --git a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx index 3af26ae02a4c..04b41b51b226 100644 --- a/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx +++ b/apps/web/src/components/settings/CaptureShortcutConfig.browser.test.tsx @@ -3,10 +3,10 @@ import { type DesktopCaptureConfigPreview, type DesktopSnapShotState, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const bridge = vi.hoisted(() => ({ previewSnapShotConfig: vi.fn(), @@ -36,18 +36,19 @@ const preview: DesktopCaptureConfigPreview = { }; const complete = vi.fn<() => Promise>(); -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; +function setup() { + const platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux"); + bridge.setSnapShotShortcutSuppressed.mockReset().mockResolvedValue(undefined); + bridge.previewSnapShotConfig.mockReset().mockResolvedValue(preview); + bridge.applySnapShotConfig + .mockReset() + .mockResolvedValue({ backupPath: "/config/backup", warning: null }); + complete.mockReset().mockResolvedValue(undefined); + toast.mockClear(); + return { [Symbol.dispose]: () => platform.mockRestore() }; } -let root: Root; -let container: HTMLDivElement; - -async function render(desktop: "niri" | "hyprland" = "niri") { +async function renderConfig(desktop: "niri" | "hyprland" = "niri") { const state: DesktopSnapShotState = { mode: "portal", linuxBackend: desktop, @@ -59,205 +60,63 @@ async function render(desktop: "niri" | "hyprland" = "niri") { shortcutConfigPath: preview.path, shortcutBinding: "manual binding", }; - await act(async () => root.render()); -} -function button(label: string) { - return [...container.querySelectorAll("button")].find((element) => element.textContent === label); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).toBeDefined(); - await act(async () => target!.click()); -} -const recorder = () => container.querySelector("[data-keybinding-capture]")!; -const diff = () => container.querySelector('[aria-label="Shortcut changes"]'); -const alert = () => container.querySelector('[role="alert"]'); -const advanced = () => container.querySelector("details")!; -async function recordKeys(keys: string) { - await act(async () => userEvent.click(recorder())); - await act(async () => userEvent.keyboard(keys)); + const screen = await render(); + const { container } = screen; + const button = (label: string) => + [...container.querySelectorAll("button")].find((element) => element.textContent === label); + const recorder = () => container.querySelector("[data-keybinding-capture]")!; + return Object.assign(screen, { + button, + recorder, + diff: () => container.querySelector('[aria-label="Shortcut changes"]'), + alert: () => container.querySelector('[role="alert"]'), + advanced: () => container.querySelector("details")!, + async click(label: string) { + const target = button(label); + expect(target, `button ${label}`).toBeDefined(); + await act(async () => target!.click()); + }, + async recordKeys(keys: string) { + await userEvent.click(recorder()); + await userEvent.keyboard(keys); + }, + }); } -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "Linux" }); - bridge.setSnapShotShortcutSuppressed.mockReset().mockResolvedValue(undefined); - bridge.previewSnapShotConfig.mockReset().mockResolvedValue(preview); - bridge.applySnapShotConfig - .mockReset() - .mockResolvedValue({ backupPath: "/config/backup", warning: null }); - complete.mockReset().mockResolvedValue(undefined); - toast.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); - it.each(["niri", "hyprland"] as const)( "shows the fallback before review and requires separate read and write approval on %s", async (desktop) => { - await render(desktop); - expect(recorder().getAttribute("aria-label")).toBe( + using _env = setup(); + await using screen = await renderConfig(desktop); + expect(screen.recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", ); - expect(advanced().textContent).toContain("manual binding"); + expect(screen.advanced().textContent).toContain("manual binding"); expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); - await click("Review changes"); - expect(recorder().getAttribute("aria-label")).toBe( + await screen.click("Review changes"); + expect(screen.recorder().getAttribute("aria-label")).toBe( "Record snapshot shortcut, currently Ctrl+Shift+2", ); expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - expect(diff()).not.toBeNull(); - await click("Save shortcut"); + expect(screen.diff()).not.toBeNull(); + await screen.click("Save shortcut"); expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id); expect(complete).toHaveBeenCalledOnce(); expect(toast).toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); }, ); -it.each(["niri", "hyprland"] as const)( - "lets users choose %s keys without opening Advanced, then approve the matching diff", - async (desktop) => { - await render(desktop); - expect(advanced().contains(recorder())).toBe(false); - await recordKeys("{Control>}{Alt>}y{/Alt}{/Control}"); - expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenNthCalledWith(1, true); - expect(bridge.setSnapShotShortcutSuppressed).toHaveBeenLastCalledWith(false); - expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); - const custom = { - ...preview, - id: "custom-keys", - shortcut: "Ctrl+Alt+Y", - after: preview.after.replace("Ctrl+Shift+2", "Ctrl+Alt+Y"), - }; - bridge.previewSnapShotConfig.mockResolvedValue(custom); - await click("Review changes"); - expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({ - operation: "install", - chooseFile: false, - shortcut: "ctrl+alt+y", - }); - expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - await click("Save shortcut"); - expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(custom.id); - expect(toast).toHaveBeenCalledWith( - expect.objectContaining({ description: "Use Ctrl+Alt+Y from another app." }), - ); - }, -); - -it("uses the existing config keys when no replacement was chosen", async () => { - bridge.previewSnapShotConfig.mockResolvedValue({ ...preview, shortcut: "Super+F8" }); - await render(); - await click("Review changes"); - expect(bridge.previewSnapShotConfig).toHaveBeenCalledExactlyOnceWith({ - operation: "install", - chooseFile: false, - }); - expect(recorder().getAttribute("aria-label")).toContain("F8"); -}); - -it("requires a new diff after changing keys during review", async () => { - await render(); - await click("Review changes"); - expect(diff()).not.toBeNull(); - await recordKeys("{Meta>}{F8}{/Meta}"); - expect(diff()).toBeNull(); - expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - const replacement = { ...preview, id: "replacement", shortcut: "Super+F8" }; - bridge.previewSnapShotConfig.mockResolvedValue(replacement); - await click("Review changes"); - expect(bridge.previewSnapShotConfig).toHaveBeenLastCalledWith({ - operation: "install", - chooseFile: false, - shortcut: "meta+f8", - }); - await click("Save shortcut"); - expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(replacement.id); -}); - -it.each(["Escape", "blur"])( - "keeps the reviewed diff when recording is cancelled with %s", - async (cancel) => { - await render(); - await click("Review changes"); - await act(async () => userEvent.click(recorder())); - expect(button("Save shortcut")?.disabled).toBe(true); - if (cancel === "Escape") await act(async () => userEvent.keyboard("{Escape}")); - else await act(async () => recorder().blur()); - expect(button("Save shortcut")?.disabled).toBe(false); - expect(bridge.previewSnapShotConfig).toHaveBeenCalledOnce(); - await click("Save shortcut"); - expect(bridge.applySnapShotConfig).toHaveBeenCalledExactlyOnceWith(preview.id); - }, -); - -it("cancelling a reviewed diff does not write or finish setup", async () => { - await render(); - await click("Review changes"); - await click("Cancel"); - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Shift+2", - ); - expect(button("Review changes")).toBeDefined(); - expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - expect(complete).not.toHaveBeenCalled(); -}); - -it("shows reading feedback until a proposal arrives", async () => { - const pending = deferred(); - bridge.previewSnapShotConfig.mockReturnValue(pending.promise); - await render(); - await click("Review changes"); - expect(button("Preparing changes…")?.disabled).toBe(true); - await act(async () => pending.resolve(preview)); - expect(button("Save shortcut")?.disabled).toBe(false); -}); - -it("keeps read failures actionable with technical details in Advanced, then permits retry", async () => { - bridge.previewSnapShotConfig.mockRejectedValueOnce(new Error("EACCES: /config/niri/config.kdl")); - await render(); - await click("Review changes"); - expect(alert()?.textContent).toBe("Couldn't prepare the changes. Check Advanced for help."); - expect(advanced().textContent).toContain("EACCES: /config/niri/config.kdl"); - await click("Review changes"); - expect(alert()).toBeNull(); - expect(button("Save shortcut")?.disabled).toBe(false); - expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); -}); - it("withdraws a stale diff and requires another read instead of retrying its write", async () => { - await render(); - await click("Review changes"); + using _env = setup(); + await using screen = await renderConfig(); + await screen.click("Review changes"); bridge.applySnapShotConfig.mockRejectedValue( new Error("Your config changed since this preview."), ); - await click("Save shortcut"); - expect(alert()?.textContent).toContain("Review the changes and try again"); - expect(advanced().textContent).toContain("Your config changed since this preview."); - expect(button("Review changes")).toBeDefined(); - expect(diff()).toBeNull(); - expect(complete).not.toHaveBeenCalled(); -}); - -it("does not finish or claim success when the desktop could not reload", async () => { - await render(); - await click("Review changes"); - bridge.applySnapShotConfig.mockResolvedValue({ - backupPath: "/config/backup", - warning: "Config saved, but reload failed.", - }); - await click("Save shortcut"); - expect(container.querySelector('[role="status"]')?.textContent).toBe( - "Saved, but the shortcut needs attention. Check Advanced for help.", - ); - expect(advanced().textContent).toContain("Config saved, but reload failed."); + await screen.click("Save shortcut"); + expect(screen.alert()?.textContent).toContain("Review the changes and try again"); + expect(screen.advanced().textContent).toContain("Your config changed since this preview."); + expect(screen.button("Review changes")).toBeDefined(); + expect(screen.diff()).toBeNull(); expect(complete).not.toHaveBeenCalled(); - expect(toast).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 82d1fb7bb83d..a97eb6abac67 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,12 +1,7 @@ -import type { AdvertisedEndpoint, DesktopUpdateState, DesktopWslState } from "@t3tools/contracts"; +import type { DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, - canRevokeOtherClients, - isQrShareableEndpoint, - isWslSettingsRowVisible, - missingTailscaleEndpointDescription, - resolveLocalVersionStatusLabel, selectQrEndpointOption, togglePairingScopeSelection, } from "./ConnectionsSettings.logic"; @@ -55,82 +50,6 @@ const baseWslState: DesktopWslState = { preflightError: null, }; -describe("resolveLocalVersionStatusLabel", () => { - const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - channel: "latest", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, - }; - - it("stays up to date on the web and when the desktop updater has nothing pending", () => { - expect(resolveLocalVersionStatusLabel(null)).toBe("Up to date"); - expect(resolveLocalVersionStatusLabel(baseState)).toBe("Up to date"); - }); - - it.each<[string, Partial, string]>([ - ["available", { status: "available", availableVersion: "1.2.0" }, "Update 1.2.0 available"], - [ - "downloading", - { status: "downloading", availableVersion: "1.2.0" }, - "Downloading update 1.2.0", - ], - [ - "downloaded", - { status: "downloaded", availableVersion: "1.2.0", downloadedVersion: "1.1.0" }, - "Update 1.1.0 downloaded", - ], - [ - "a failed download that can be retried", - { status: "error", errorContext: "download", availableVersion: "1.2.0", canRetry: true }, - "Update 1.2.0 available", - ], - [ - "a failed install", - { status: "error", errorContext: "install", downloadedVersion: "1.1.0" }, - "Update 1.1.0 downloaded", - ], - [ - "a recheck that keeps a downloaded update", - { status: "checking", downloadedVersion: "1.1.0" }, - "Update 1.1.0 downloaded", - ], - ])("names the pending update for %s", (_, state, label) => { - expect(resolveLocalVersionStatusLabel({ ...baseState, ...state })).toBe(label); - }); -}); - -describe("isWslSettingsRowVisible", () => { - it("shows the retry row when the WSL state failed to load", () => { - expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); - }); - - it("hides an unavailable and unused WSL snapshot", () => { - expect( - isWslSettingsRowVisible({ - state: { ...baseWslState, available: false, wslOnly: false }, - error: null, - }), - ).toBe(false); - }); - - it("shows an available WSL snapshot", () => { - expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); - }); -}); - describe("applyWslEnableSelection", () => { it("clears WSL-only and updates the distro before enabling both backends", async () => { const calls: Array = []; @@ -166,69 +85,6 @@ describe("applyWslEnableSelection", () => { expect(calls).toEqual(["setWslOnly:false", "setWslDistro:Debian", "setWslBackendEnabled:true"]); expect(state).toMatchObject({ enabled: true, distro: "Debian", wslOnly: false }); }); - - it("stages WSL-only before enabling without rewriting an unchanged distro", async () => { - const calls: Array = []; - let persistedWslOnly = false; - const setWslDistro = vi.fn(async () => baseWslState); - const setWslOnly = vi.fn(async (enabled: boolean) => { - calls.push(`setWslOnly:${enabled}`); - persistedWslOnly = enabled; - return { ...baseWslState, wslOnly: enabled }; - }); - const setWslBackendEnabled = vi.fn(async (enabled: boolean) => { - calls.push(`setWslBackendEnabled:${enabled}`); - return { ...baseWslState, enabled, wslOnly: persistedWslOnly }; - }); - - const state = await applyWslEnableSelection({ - bridge: { setWslDistro, setWslBackendEnabled, setWslOnly }, - mode: "wsl-only", - nextDistro: null, - persistedDistro: null, - }); - - expect(calls).toEqual(["setWslOnly:true", "setWslBackendEnabled:true"]); - expect(setWslDistro).not.toHaveBeenCalled(); - expect(state).toMatchObject({ enabled: true, wslOnly: true }); - }); -}); - -function makeEndpoint(overrides: Partial): AdvertisedEndpoint { - return { - id: "desktop-lan:http://192.168.1.42:4780", - label: "Local network", - provider: { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false }, - httpBaseUrl: "http://192.168.1.42:4780", - wsBaseUrl: "ws://192.168.1.42:4780", - reachability: "lan", - compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" }, - source: "desktop-core", - status: "available", - ...overrides, - }; -} - -describe("isQrShareableEndpoint", () => { - it("excludes loopback endpoints so a scanned phone never dials itself", () => { - expect( - isQrShareableEndpoint( - makeEndpoint({ - id: "desktop-loopback:4780", - reachability: "loopback", - httpBaseUrl: "http://127.0.0.1:4780", - }), - ), - ).toBe(false); - }); - - it("excludes unavailable endpoints and keeps reachable ones", () => { - expect(isQrShareableEndpoint(makeEndpoint({ status: "unavailable" }))).toBe(false); - expect(isQrShareableEndpoint(makeEndpoint({}))).toBe(true); - expect( - isQrShareableEndpoint(makeEndpoint({ reachability: "private-network", status: "unknown" })), - ).toBe(true); - }); }); describe("selectQrEndpointOption", () => { @@ -261,50 +117,9 @@ describe("selectQrEndpointOption", () => { ); }); - it("falls back to the saved default preference key when nothing is selected", () => { - expect(selectQrEndpointOption(options, null, "desktop-core:lan:http")?.id).toBe( - "desktop-lan:http://192.168.1.42:4780", - ); - }); - it("skips non-QR-shareable options in the fallback so the panel never opens on loopback", () => { expect(selectQrEndpointOption(options, "tailscale-ip:gone", "nope")?.id).toBe( "tailscale-ip:http://100.84.12.7:4780", ); }); - - it("returns the first option when nothing is QR-shareable, and null when empty", () => { - const loopbackOnly = options.slice(0, 1); - expect(selectQrEndpointOption(loopbackOnly, null, null)?.id).toBe("desktop-loopback:4780"); - expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); - }); -}); - -describe("canRevokeOtherClients", () => { - it("permits revocation when a write-only grant cannot list clients", () => { - expect(canRevokeOtherClients(null)).toBe(true); - }); - - it("disables revocation only when a loaded list has no other clients", () => { - expect(canRevokeOtherClients([])).toBe(false); - expect(canRevokeOtherClients([{ current: true }])).toBe(false); - expect(canRevokeOtherClients([{ current: true }, { current: false }])).toBe(true); - }); -}); - -describe("missingTailscaleEndpointDescription", () => { - it("explains that detection waits for Network access when the probe was skipped", () => { - expect( - missingTailscaleEndpointDescription({ mode: "local-only", tailscaleServeEnabled: false }), - ).toBe("Turn on Network access to detect Tailscale."); - }); - - it.each([ - { mode: "network-accessible", tailscaleServeEnabled: false }, - { mode: "local-only", tailscaleServeEnabled: true }, - ] as const)("asks to start Tailscale when it was probed and not found (%o)", (state) => { - expect(missingTailscaleEndpointDescription(state)).toBe( - "Start Tailscale to set up HTTPS access through MagicDNS.", - ); - }); }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index efe675d93ed3..164ce5d909d5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2205,7 +2205,6 @@ export function ConnectionsSettings() { const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = useState(null); const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); - const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); const [isUpdatingWslBackend, setIsUpdatingWslBackend] = useState(false); const [desktopWslMutationError, setDesktopWslMutationError] = useState(null); @@ -2353,11 +2352,11 @@ export function ConnectionsSettings() { try { await desktopBridge.setServerExposureMode(checked ? "network-accessible" : "local-only"); refreshDesktopNetworkAccessState(); - setIsDesktopServerExposureDialogOpen(false); + setPendingDesktopServerExposureMode(null); setIsUpdatingDesktopServerExposure(false); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update network exposure."; - setIsDesktopServerExposureDialogOpen(false); + setPendingDesktopServerExposureMode(null); setDesktopServerExposureMutationError(message); toastManager.add( stackedThreadToast({ @@ -3054,7 +3053,6 @@ export function ConnectionsSettings() { disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure} onCheckedChange={(checked) => { setPendingDesktopServerExposureMode(checked ? "network-accessible" : "local-only"); - setIsDesktopServerExposureDialogOpen(true); }} aria-label="Enable network access" /> @@ -3288,16 +3286,9 @@ export function ConnectionsSettings() { Off {desktopWslState.distros.length === 0 ? ( - <> - - Default distro - - {desktopWslState.distro !== null ? ( - - {desktopWslState.distro} - - ) : null} - + + Default distro + ) : ( desktopWslState.distros.map((distro) => ( @@ -3627,13 +3618,10 @@ export function ConnectionsSettings() { ) : null} { - if (isUpdatingDesktopServerExposure) return; - setIsDesktopServerExposureDialogOpen(open); - }} - onOpenChangeComplete={(open) => { - if (!open) setPendingDesktopServerExposureMode(null); + if (!open && !isUpdatingDesktopServerExposure) + setPendingDesktopServerExposureMode(null); }} > diff --git a/apps/web/src/components/settings/CustomModelEditor.tsx b/apps/web/src/components/settings/CustomModelEditor.tsx index e0061a327d4a..c4c7821ce971 100644 --- a/apps/web/src/components/settings/CustomModelEditor.tsx +++ b/apps/web/src/components/settings/CustomModelEditor.tsx @@ -1,5 +1,3 @@ -"use client"; - import { PlusIcon, XIcon } from "lucide-react"; import { useState } from "react"; import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; diff --git a/apps/web/src/components/settings/DatabaseStorageNotice.tsx b/apps/web/src/components/settings/DatabaseStorageNotice.tsx index 637c920f5ed0..08ccf816a9e1 100644 --- a/apps/web/src/components/settings/DatabaseStorageNotice.tsx +++ b/apps/web/src/components/settings/DatabaseStorageNotice.tsx @@ -1,60 +1,70 @@ import { useAtomValue } from "@effect/atom-react"; -import { shouldRecommendDatabaseCompaction, type EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { serverEnvironment } from "../../state/server"; import { Button } from "../ui/button"; -import { SettingsGroup } from "./SettingsGroup"; import { SettingsRow, SettingsSection } from "./settingsLayout"; -/** Suggests `t3 doctor` for a database that still holds at least 1 GiB of freed pages. */ +const DismissedEnvironmentIds = Schema.Array(Schema.String); + +/** Suggests `t3 doctor` on every machine whose database still holds at least 1 GiB of freed pages. */ export function DatabaseStorageNotice({ - environmentId, - label, + environments, }: { - environmentId: EnvironmentId; - label: string; + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; }) { - const result = useAtomValue(serverEnvironment.databaseStorage({ environmentId, input: {} })); + const recommendations = useAtomValue( + serverEnvironment.databaseCompactionRecommendations( + JSON.stringify(environments.map((environment) => environment.environmentId)), + ), + ); const [dismissed, setDismissed] = useLocalStorage( - `database-storage-notice-dismissed:${environmentId}`, - false, - Schema.Boolean, + "database-storage-notice-dismissed", + [], + DismissedEnvironmentIds, ); const { copyToClipboard } = useCopyToClipboard({ target: "command" }); - const status = result._tag === "Success" ? result.value : null; - if (dismissed || status === null || !shouldRecommendDatabaseCompaction(status)) return null; + const visible = recommendations.filter( + (recommendation) => !dismissed.includes(recommendation.environmentId), + ); + if (visible.length === 0) return null; return ( - - + + + {visible.map(({ environmentId, status }) => ( - Old history is cleaned up automatically, but this database still holds the freed - space. Run the command on {label} to return it to disk; it pauses and restarts the - background service. Quit the desktop app first if it hosts this environment. - {status.compactCommand} - - } + key={environmentId} + title={`${(status.reclaimableBytes / 1024 ** 3).toFixed(1)} GiB on ${ + environments.find((environment) => environment.environmentId === environmentId) + ?.label ?? environmentId + }`} + description={{status.compactCommand}} control={
-
} /> -
+ ))}
); } diff --git a/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx b/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx deleted file mode 100644 index 0563ba0eb3ae..000000000000 --- a/apps/web/src/components/settings/DeviceToolUpdate.browser.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; -import { - DeviceOperationError, - EnvironmentId, - LOCAL_DEVICE_HOST_ID, - type DeviceServiceState, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const { list } = vi.hoisted(() => ({ list: vi.fn() })); - -vi.mock("~/state/use-atom-command", () => ({ - useAtomCommand: (command: unknown) => (command === "list" ? list : vi.fn()), -})); -vi.mock("~/state/device", () => ({ - deviceEnvironment: { list: "list", configure: "configure" }, - useDeviceState: () => ({ state: deviceState, loaded: true }), -})); -vi.mock("~/state/session", async (importOriginal) => ({ - ...(await importOriginal()), - useEnvironmentScope: () => true, - readEnvironmentScope: () => true, -})); -vi.mock("./SettingsScopeContext", () => ({ - useSettingsScope: () => ({ - scope: { kind: "all", environmentIds: [] }, - search: {}, - environment: null, - environments: [], - target: null, - connectedEnvironments: [], - targets: [], - }), - useOptionalSettingsScope: () => null, -})); -vi.mock("./useScopedSettings", async (importOriginal) => ({ - ...(await importOriginal()), - useUpdateScopedSettings: () => vi.fn(), -})); -// Rows resolve scope and permissions; only their controls matter here. -vi.mock("./settingsLayout", async (importOriginal) => ({ - ...(await importOriginal()), - SettingsRow: ({ control, children }: { control?: ReactNode; children?: ReactNode }) => ( - <> - {control} - {children} - - ), -})); -// The version popover holds the Update button; render the button in place of the popover. -vi.mock("../device/DeviceToolVersions", () => ({ - DeviceToolVersions: ({ action }: { action?: ReactNode }) => action ?? null, -})); -vi.mock("../device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); -vi.mock("./DeviceHostsSettings", () => ({ DeviceHostsSettings: () => null })); - -import { DeviceIntegrationControls } from "./IntegrationsSettings"; - -const environmentId = EnvironmentId.make("environment-1"); -const deviceState = { - supportsToolUpdate: true, - hosts: [ - { - id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "This computer", - platforms: [], - tools: { - hub: { requiredVersion: "0.12.0", installedVersions: [], runningVersion: null }, - agent: { requiredVersion: "1.0.0", installedVersions: ["1.0.0"], runningVersion: null }, - }, - hubInstalled: false, - agentDeviceInstalled: true, - }, - ], - hostStatus: "idle", - hostStatuses: {}, - devices: [], - sessions: [], - onboardingCompleted: true, - agentAccessEnabled: false, - hubBasePath: "/device-hub", - revision: 1, -} satisfies DeviceServiceState; - -let root: Root | undefined; -let container: HTMLDivElement | undefined; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - list.mockReset(); -}); - -afterEach(async () => { - await act(() => root?.unmount()); - container?.remove(); - vi.unstubAllGlobals(); -}); - -it.each([ - { - name: "a failed install", - error: new DeviceOperationError({ - operation: "update device tool", - reason: "command_failed", - exitCode: 243, - cause: new Error("npm error EACCES https://private:credential@registry.example.test/hub"), - }), - message: "Device update device tool failed: The device command failed (exit code 243).", - }, - { - name: "a lost connection", - error: new EnvironmentRpcUnavailableError({ - environmentId, - message: "Query environment is not connected.", - }), - message: "Query environment is not connected.", - }, -])( - "reports $name as the server describes it, not as a network problem", - async ({ error, message }) => { - list.mockResolvedValue({ _tag: "Failure", cause: Cause.fail(error) }); - container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); - await act(() => { - root!.render( - , - ); - }); - - const update = [...container.querySelectorAll("button")].find( - (button) => button.textContent === "Update to v0.12.0", - ); - expect(update).toBeDefined(); - await act(async () => update!.click()); - - expect(list).toHaveBeenCalledWith({ environmentId, input: { updateTool: "hub" } }); - expect(container.querySelector('[role="alert"]')?.textContent).toBe(message); - }, -); diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.test.ts b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts index 25fd930e3b6b..447b4f8ccfa3 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.test.ts +++ b/apps/web/src/components/settings/EnvironmentIconPicker.test.ts @@ -9,27 +9,6 @@ const config = (environmentIcon: boolean | undefined) => }) as unknown as ServerConfig; describe("resolveEnvironmentIconPickerLock", () => { - it("locks until the environment is connected", () => { - expect( - resolveEnvironmentIconPickerLock({ serverConfig: null, operateAccess: "granted" }), - ).toMatch(/Connect/); - }); - - it("locks on servers that predate the setting, before looking at permissions", () => { - expect( - resolveEnvironmentIconPickerLock({ - serverConfig: config(undefined), - operateAccess: "denied", - }), - ).toMatch(/too old/); - }); - - it("locks when the session cannot operate the environment", () => { - expect( - resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "denied" }), - ).toMatch(/cannot change/); - }); - it("waits for a settings grant before allowing changes", () => { expect( resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), diff --git a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx b/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx deleted file mode 100644 index 997fcae85b4c..000000000000 --- a/apps/web/src/components/settings/FontFamilyPicker.browser.test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -const families = vi.hoisted(() => { - const families = [ - "Arial", - "Courier New", - "Fira Code", - "Georgia", - "Helvetica", - "JetBrains Mono", - "Menlo", - "Times New Roman", - ]; - // The picker decides at module load whether the engine can list fonts. - Object.assign(window, { queryLocalFonts: async () => families.map((family) => ({ family })) }); - return families; -}); - -vi.mock("../../appearanceFonts", () => ({ - isMonospaceFamily: () => true, - queryInstalledFontFamilies: async () => ({ status: "granted", families }), -})); - -import { discoverInstalledFonts, FontFamilyPicker } from "./FontFamilyPicker"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(async () => discoverInstalledFonts()); -}); - -afterEach(async () => { - await act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function highlightedOptions(): string[] { - return [...document.querySelectorAll('[role="option"][data-highlighted]')].map( - (option) => option.textContent ?? "", - ); -} - -describe("FontFamilyPicker", () => { - it("highlights, moves through, and selects matches after filtering", async () => { - const onSelect = vi.fn(); - await act(async () => - root.render( - , - ), - ); - - const input = document.querySelector('input[placeholder="Search fonts…"]'); - expect(input).not.toBeNull(); - const press = (key: string) => act(() => userEvent.keyboard(`{${key}}`)); - - // Both matches were already rendered further down the unfiltered list. - await act(() => userEvent.type(input!, "co")); - await expect.poll(highlightedOptions).toEqual(["Courier New"]); - - await press("ArrowDown"); - expect(highlightedOptions()).toEqual(["Fira Code"]); - - await press("Enter"); - expect(onSelect).toHaveBeenCalledWith("Fira Code"); - }); -}); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.test.ts b/apps/web/src/components/settings/GitHubRoutingSettings.test.ts deleted file mode 100644 index 5f09ab69c99d..000000000000 --- a/apps/web/src/components/settings/GitHubRoutingSettings.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { summarizeGitHubRouting } from "./GitHubRoutingSettings"; - -describe("summarizeGitHubRouting", () => { - it("is empty when no machine shares", () => { - expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull(); - }); - - it("groups sharing machines by permission, read and act first", () => { - expect( - summarizeGitHubRouting([ - { label: "alvin", permission: "read" }, - { label: "bb-1", permission: "read-write" }, - { label: "cup2", permission: "off" }, - { label: "Theo's MacBook Pro", permission: "read-write" }, - ]), - ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs"); - }); -}); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.tsx b/apps/web/src/components/settings/GitHubRoutingSettings.tsx index 14d4aaf37643..235a7f810a1f 100644 --- a/apps/web/src/components/settings/GitHubRoutingSettings.tsx +++ b/apps/web/src/components/settings/GitHubRoutingSettings.tsx @@ -24,7 +24,7 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const; -export function summarizeGitHubRouting( +function summarizeGitHubRouting( entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>, ): string | null { const groups = (["read-write", "read"] as const).flatMap((permission) => { diff --git a/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx deleted file mode 100644 index abb14f61b47b..000000000000 --- a/apps/web/src/components/settings/IntegrationsSettings.browser.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; -import { - createMemoryHistory, - createRootRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { act, StrictMode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; - -import "../../index.css"; - -const { listBrowserImportSources } = vi.hoisted(() => ({ - listBrowserImportSources: vi.fn().mockResolvedValue([]), -})); - -vi.mock(import("../preview/previewBridge"), async (original) => { - const actual = await original(); - return { - ...actual, - previewBridge: { ...actual.previewBridge, listBrowserImportSources } as never, - }; -}); -vi.mock(import("../../env"), async (original) => ({ ...(await original()), isElectron: true })); -vi.mock(import("../../state/environments"), async (original) => ({ - ...(await original()), - useEnvironments: (() => ({ environments: [], isReady: true })) as never, - usePrimaryEnvironment: () => null, - usePrimaryEnvironmentId: () => null, -})); -vi.mock(import("../../hooks/useSettings"), async (original) => ({ - ...(await original()), - useClientSettings: ((selector?: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => - selector ? selector(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS) as never, - useClientSettingsHydrated: () => true, - usePrimarySettingsAvailable: () => true, - usePrimarySettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, - useUpdatePrimarySettings: (() => vi.fn()) as never, -})); -vi.mock(import("./SettingsScopeContext"), async (original) => ({ - ...(await original()), - useSettingsScope: (() => ({ - scope: { kind: "all", environmentIds: [] }, - search: {}, - environment: null, - environments: [], - target: null, - connectedEnvironments: [], - targets: [], - })) as never, - useOptionalSettingsScope: () => null, -})); - -import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - listBrowserImportSources.mockClear(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -async function openSettings() { - const router = createRouter({ - routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), - history: createMemoryHistory(), - }); - await router.load(); - await act(async () => - root.render( - - - , - ), - ); - await vi.waitFor(() => expect(container.querySelector("section#browser")).not.toBeNull()); -} - -describe("Integrations browser discovery", () => { - it("keeps agent browser access in the single Browser section", async () => { - await openSettings(); - - expect(page.getByRole("heading", { name: "Browser", exact: true }).elements()).toHaveLength(1); - const access = page.getByRole("switch", { name: "Agent browser access", exact: true }); - await expect.element(access).toBeVisible(); - expect(access.element().closest("section")).toBe(container.querySelector("section#browser")); - expect(access.element().closest("#agent-browser-access")).not.toBeNull(); - }); - - it("does not scan browser files when entering or revisiting settings", async () => { - await openSettings(); - expect(listBrowserImportSources).not.toHaveBeenCalled(); - - await act(async () => root.unmount()); - root = createRoot(container); - await openSettings(); - expect(listBrowserImportSources).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts index 90e91d7ab90e..d25089f723bc 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -1,60 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentId } from "@t3tools/contracts"; -import { - browserProfileRemovalAvailable, - clearBrowserProfileData, - importFailureReason, -} from "./IntegrationsSettings"; +import { clearBrowserProfileData, importFailureReason } from "./IntegrationsSettings"; const environmentId = "environment-a" as EnvironmentId; const secondEnvironmentId = "environment-b" as EnvironmentId; describe("clearBrowserProfileData", () => { - it("waits for cookie and cache cleanup", async () => { - const clearCookies = vi.fn().mockResolvedValue(undefined); - const clearCache = vi.fn().mockResolvedValue(undefined); - - await clearBrowserProfileData({ clearCookies, clearCache }, [environmentId], "profile-a"); - - expect(clearCookies).toHaveBeenCalledWith(environmentId, "profile-a"); - expect(clearCache).toHaveBeenCalledWith(environmentId, "profile-a"); - }); - - it("clears every known environment before succeeding", async () => { - const clearCookies = vi.fn().mockResolvedValue(undefined); - const clearCache = vi.fn().mockResolvedValue(undefined); - - await clearBrowserProfileData( - { clearCookies, clearCache }, - [environmentId, secondEnvironmentId], - "profile-a", - ); - - expect(clearCookies.mock.calls).toEqual([ - [environmentId, "profile-a"], - [secondEnvironmentId, "profile-a"], - ]); - expect(clearCache.mock.calls).toEqual([ - [environmentId, "profile-a"], - [secondEnvironmentId, "profile-a"], - ]); - }); - - it("propagates cleanup failures", async () => { - const failure = new Error("clear failed"); - await expect( - clearBrowserProfileData( - { - clearCookies: vi.fn().mockRejectedValue(failure), - clearCache: vi.fn().mockResolvedValue(undefined), - }, - [environmentId], - "profile-a", - ), - ).rejects.toBe(failure); - }); - it("does not report success without an environment or bridge", async () => { const bridge = { clearCookies: vi.fn().mockResolvedValue(undefined), @@ -94,19 +46,6 @@ describe("clearBrowserProfileData with server browsers", () => { }); }); -describe("browserProfileRemovalAvailable", () => { - it("accepts a server browser in place of the desktop bridge", () => { - expect(browserProfileRemovalAvailable(false, true, 1, 1)).toBe(true); - }); - - it("requires a ready non-empty catalog and desktop bridge", () => { - expect(browserProfileRemovalAvailable(true, true, 1)).toBe(true); - expect(browserProfileRemovalAvailable(true, true, 0)).toBe(false); - expect(browserProfileRemovalAvailable(true, false, 1)).toBe(false); - expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false); - }); -}); - // Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string // before the renderer sees it. const failure = (reason: string) => ({ @@ -122,9 +61,4 @@ describe("importFailureReason", () => { expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved"); expect(importFailureReason(failure("profileLimitReached"))).toBe("profileLimitReached"); }); - - it("falls back to readFailed for anything it cannot classify", () => { - expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed"); - expect(importFailureReason(undefined)).toBe("readFailed"); - }); }); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 6cf5e6f52d5f..55c2936e5f00 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -162,7 +162,7 @@ export async function clearBrowserProfileData( ]); } -export function browserProfileRemovalAvailable( +function browserProfileRemovalAvailable( bridgeAvailable: boolean, environmentsReady: boolean, environmentCount: number, @@ -827,7 +827,7 @@ function DeviceIntegrationSettings() { ); } -export function DeviceIntegrationControls({ +function DeviceIntegrationControls({ environmentId, enabled, agentAccessEnabled, diff --git a/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx deleted file mode 100644 index 2f438dfc4397..000000000000 --- a/apps/web/src/components/settings/KeybindingsSettings.environment.browser.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - canOperate: false, - canWriteSettings: true, - connected: true, - openEditor: vi.fn(), -})); - -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useLocation: (() => "") as never, -})); -vi.mock(import("./SettingsScopeContext"), async (original) => ({ - ...(await original()), - useSettingsScope: () => { - const environment = { - environmentId: "primary-settings", - serverConfig: { - keybindings: [], - keybindingsConfigPath: "/fixture/keybindings.json", - availableEditors: [], - }, - }; - return ( - state.connected - ? { environment, connectedEnvironments: [environment] } - : { environment: null, connectedEnvironments: [] } - ) as never; - }, -})); -vi.mock(import("../../state/session"), async (original) => { - const hasScope = (environmentId: EnvironmentId | null, scope: string) => - environmentId === "primary-settings" && - (scope === "orchestration:operate" - ? state.canOperate - : scope === "settings:write" && state.canWriteSettings); - return { - ...(await original()), - useEnvironmentScope: hasScope as never, - readEnvironmentScope: hasScope as never, - useEnvironmentsWithScope: (() => - new Set(state.canWriteSettings ? ["primary-settings"] : [])) as never, - }; -}); -vi.mock(import("../../editorPreferences"), async (original) => ({ - ...(await original()), - useOpenInPreferredEditor: ((environmentId: EnvironmentId) => (path: string) => - state.openEditor({ environmentId, path })) as never, -})); - -import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; - -let root: Root; -let container: HTMLDivElement; - -const render = () => act(async () => root.render()); -const openButton = () => { - const button = container.querySelector( - 'button[aria-label="Open keybindings.json"]', - ); - expect(button).not.toBeNull(); - return button!; -}; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.canOperate = false; - state.canWriteSettings = true; - state.connected = true; - state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -describe("KeybindingsSettings editor permission", () => { - it("rejects editor launches when settings writes are the only write grant", async () => { - await render(); - expect(openButton().disabled).toBe(true); - await act(async () => openButton().click()); - expect(state.openEditor).not.toHaveBeenCalled(); - }); - - it("opens the primary environment's file after operate is granted without settings writes", async () => { - state.canWriteSettings = false; - await render(); - state.canOperate = true; - await render(); - expect(openButton().disabled).toBe(false); - await act(async () => openButton().click()); - - expect(state.openEditor).toHaveBeenCalledWith({ - environmentId: "primary-settings", - path: "/fixture/keybindings.json", - }); - }); - - it("rejects a queued editor launch after operate is revoked", async () => { - state.canOperate = true; - await render(); - const button = openButton(); - state.canOperate = false; - // The rendered button is still enabled; the click must recheck the live grant. - await act(async () => button.click()); - - expect(state.openEditor).not.toHaveBeenCalled(); - await render(); - expect(openButton().disabled).toBe(true); - }); -}); - -describe("KeybindingsSettings write notice", () => { - it("asks for a connected environment when none is connected", async () => { - state.connected = false; - await render(); - expect(container.textContent).toContain("Connect an environment to change keybindings."); - }); - - it("names the permission when the connection cannot write settings", async () => { - state.canWriteSettings = false; - await render(); - expect(container.textContent).toContain( - "This connection can view keybindings but cannot change them.", - ); - }); -}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 050ff8ab618f..3ea228bfeb2d 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -9,19 +9,15 @@ import { import { resolveShortcutCommand } from "../../keybindings"; import { - buildKeybindingRows, buildKeybindingCommandOptions, - buildWhenVariableOptions, - commandLabel, + buildKeybindingRows, filterKeybindingRows, groupKeybindingRows, keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, shortcutToKeybindingInput, - unknownWhenVariables, whenAstToExpression, - whenNodeRemoveLabel, } from "./KeybindingsSettings.logic"; describe("KeybindingsSettings.logic", () => { @@ -166,40 +162,6 @@ describe("KeybindingsSettings.logic", () => { ); }); - it("builds searchable rows with readable key and when values", () => { - const rows = filterKeybindingRows( - buildKeybindingRows([ - { - command: "terminal.toggle", - shortcut: { - key: "j", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ] satisfies ResolvedKeybindingsConfig), - "terminal", - ); - - expect(rows).toEqual([ - expect.objectContaining({ - command: "terminal.toggle", - key: "mod+j", - when: "!terminalFocus", - defaultKey: "mod+j", - defaultWhen: "", - source: "Custom", - }), - ]); - }); - it("captures platform-specific mod shortcuts", () => { expect( keybindingFromKeyboardEvent( @@ -215,15 +177,6 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); - it.each([ - ["k", "KeyK", "k"], - ["Tab", "Tab", "tab"], - ["F5", "F5", "f5"], - ])("captures %s without a modifier", (key, code, expected) => { - const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; - expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected); - }); - it("waits for a key when only a modifier is pressed", () => { expect( keybindingFromKeyboardEvent( @@ -301,32 +254,6 @@ describe("KeybindingsSettings.logic", () => { } }); - it("records an unshifted brace literally and a US shifted brace as Shift+[; both trigger the saved binding", () => { - for (const { shiftKey, expected } of [ - { shiftKey: false, expected: "mod+{" }, - { shiftKey: true, expected: "mod+shift+[" }, - ]) { - const pressed = { - key: "{", - code: "BracketLeft", - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey, - }; - const input = keybindingFromKeyboardEvent(pressed, "MacIntel", { layoutCharacter: true }); - expect(input).toBe(expected); - assert(input); - const shortcut = parseKeybindingShortcut(input); - assert(shortcut); - expect( - resolveShortcutCommand(pressed, [{ command: "thread.previous", shortcut }], { - platform: "MacIntel", - }), - ).toBe("thread.previous"); - } - }); - it("captures Latin layout keys instead of their punctuation position", () => { expect( keybindingFromKeyboardEvent( @@ -399,121 +326,6 @@ describe("KeybindingsSettings.logic", () => { }); }); - it("describes the scope of each visual expression removal", () => { - const condition = { type: "identifier", name: "terminalFocus" } as const; - const negatedCondition = { type: "not", node: condition } as const; - const group = { type: "and", left: condition, right: negatedCondition } as const; - const negatedGroup = { type: "not", node: group } as const; - - expect(whenNodeRemoveLabel(group, 0)).toBe("Clear all conditions"); - expect(whenNodeRemoveLabel(condition, 1)).toBe("Remove condition"); - expect(whenNodeRemoveLabel(negatedCondition, 1)).toBe("Remove condition"); - expect(whenNodeRemoveLabel(group, 1)).toBe("Remove group and its conditions"); - expect(whenNodeRemoveLabel(negatedGroup, 1)).toBe("Remove group and its conditions"); - }); - - it("formats static and project script command labels", () => { - expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); - expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); - expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab"); - expect(commandLabel("preview.focusUrl")).toBe("Preview: Focus URL"); - expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); - }); - - it("builds known when variable options from defaults without frontend labels", () => { - const options = buildWhenVariableOptions(); - - expect(options).toEqual( - expect.arrayContaining([ - "terminalFocus", - "terminalOpen", - "isWeb", - "isDesktop", - "modelPickerOpen", - "true", - "false", - ]), - ); - expect(options).not.toContain("customModeActive"); - }); - - it("builds command options from all static commands and resolved project bindings", () => { - const options = buildKeybindingCommandOptions([ - { - command: "script.setup-db.run", - shortcut: { - key: "r", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - }, - ] satisfies ResolvedKeybindingsConfig); - - expect(options).toEqual( - expect.arrayContaining([ - "chat.new", - "threadPanel.toggle", - "rightPanel.toggleMaximized", - "composer.cycleHost", - "thread.stop", - "usage.open", - "script.setup-db.run", - ]), - ); - for (const command of ["thread.stop", "composer.cycleHost"]) { - expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe( - false, - ); - } - }); - - it("reports unknown when variables without rejecting parseable expressions", () => { - const parsed = parseWhenExpressionDraft("!terminalFocus && terminalFoc"); - - expect(parsed.ok).toBe(true); - expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]); - }); - - it("marks each default shortcut for multi-binding commands as default", () => { - const rows = buildKeybindingRows([ - { - command: "chat.new", - shortcut: { - key: "n", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - { - command: "chat.new", - shortcut: { - key: "o", - modKey: true, - metaKey: false, - ctrlKey: false, - altKey: false, - shiftKey: true, - }, - whenAst: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - ] satisfies ResolvedKeybindingsConfig); - - expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]); - }); - it("reports conflicting shortcuts that share an active when context", () => { const rows = buildKeybindingRows([ { diff --git a/apps/web/src/components/settings/LoadBalancingSettings.test.ts b/apps/web/src/components/settings/LoadBalancingSettings.test.ts deleted file mode 100644 index 53e3c16d6f8d..000000000000 --- a/apps/web/src/components/settings/LoadBalancingSettings.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings"; - -const machines = [ - { environmentId: EnvironmentId.make("a"), label: "alvin" }, - { environmentId: EnvironmentId.make("b"), label: "bb-1" }, - { environmentId: EnvironmentId.make("c"), label: "ProMini" }, -]; - -describe("loadPreferenceForWeight", () => { - it("snaps legacy slider weights onto the four preferences", () => { - expect(loadPreferenceForWeight(undefined)).toBe(50); - expect(loadPreferenceForWeight(0)).toBe(0); - expect(loadPreferenceForWeight(10)).toBe(25); - expect(loadPreferenceForWeight(50)).toBe(50); - expect(loadPreferenceForWeight(80)).toBe(100); - }); -}); - -describe("summarizeLoadPreferences", () => { - it("is empty when every machine is at Normal", () => { - expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull(); - }); - - it("lists only the machines that differ from Normal, in list order", () => { - expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe( - "bb-1 prefer · ProMini manual only", - ); - }); -}); diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index 63fde84c8781..84198c24a960 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -21,7 +21,7 @@ const preferences = [ type LoadPreference = (typeof preferences)[number]["value"]; -export function loadPreferenceForWeight(weight: number | undefined): LoadPreference { +function loadPreferenceForWeight(weight: number | undefined): LoadPreference { if (weight === undefined || weight === 50) return 50; if (weight === 0) return 0; return weight < 50 ? 25 : 100; @@ -31,7 +31,7 @@ function preferenceLabel(preference: LoadPreference): string { return preferences.find((entry) => entry.value === preference)!.label; } -export function summarizeLoadPreferences( +function summarizeLoadPreferences( environments: ReadonlyArray>, weights: Readonly>, ): string | null { diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx deleted file mode 100644 index 62288a12b007..000000000000 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.browser.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { RegistryContext } from "@effect/atom-react"; -import { EnvironmentId, type AssetCreateUrlResult, type ProjectEntry } from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -import "~/index.css"; - -type AssetResult = AsyncResult.AsyncResult; -const state = vi.hoisted(() => ({ - entries: [] as ProjectEntry[], - assets: new Map>(), - canReadFiles: true, - createUrl: vi.fn(), -})); -const toast = vi.hoisted(() => vi.fn()); -vi.mock(import("~/state/assets"), async (original) => ({ - ...(await original()), - assetEnvironment: { createUrl: state.createUrl, createUrls: vi.fn() }, -})); -vi.mock(import("~/state/session"), async (original) => ({ - ...(await original()), - usePreparedConnection: () => - ({ - _tag: "Some" as const, - value: { httpBaseUrl: location.origin }, - }) as ReturnType, -})); -vi.mock(import("~/state/filesystem"), async (original) => ({ - ...(await original()), - useFilesystemReadAccess: () => ({ - canReadFiles: state.canReadFiles, - isPending: false, - error: null, - }), -})); -vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ - ...(await original()), - useProjectFilePickerQuery: () => ({ - entries: state.entries, - error: null, - isPending: false, - matchedQuery: "", - }), -})); -vi.mock(import("../ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: toast } }; -}); - -import { appAtomRegistry } from "~/rpc/atomRegistry"; - -import { - canPickExternalProjectFavicon, - ProjectFaviconPickerDialog, -} from "./ProjectFaviconPickerDialog"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "MacIntel" }); - toast.mockClear(); - state.entries = []; - state.assets.clear(); - state.canReadFiles = true; - state.createUrl - .mockReset() - .mockImplementation(({ input }) => state.assets.get(input.resource.path)!); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); - -async function renderPicker(onPickExternal?: () => Promise) { - const onOpenChange = vi.fn(); - const onSelect = vi.fn(); - await act(async () => - root.render( - - - , - ), - ); - return { onOpenChange, onSelect }; -} -async function clickOpenInFinder() { - const button = [...document.querySelectorAll("button")].find((element) => - element.textContent?.includes("Open in Finder"), - ); - expect(button, "Open in Finder").toBeDefined(); - await act(async () => button!.click()); -} - -it("selects an image from the native file picker", async () => { - const onPickExternal = vi.fn().mockResolvedValue("/Users/me/Pictures/icon.png"); - const { onOpenChange, onSelect } = await renderPicker(onPickExternal); - - await clickOpenInFinder(); - - expect(onPickExternal).toHaveBeenCalledTimes(1); - expect(onOpenChange).toHaveBeenCalledWith(false); - expect(onSelect).toHaveBeenCalledWith("/Users/me/Pictures/icon.png"); -}); - -it("hides the native picker for WSL project paths", () => { - expect(canPickExternalProjectFavicon("/home/me/project", "Win32")).toBe(false); - expect(canPickExternalProjectFavicon("C:\\Users\\me\\project", "Win32")).toBe(true); -}); - -it("keeps the dialog open when the native picker fails", async () => { - const { onOpenChange, onSelect } = await renderPicker( - vi.fn().mockRejectedValue(new Error("picker failed")), - ); - - await clickOpenInFinder(); - - expect(onOpenChange).not.toHaveBeenCalled(); - expect(onSelect).not.toHaveBeenCalled(); - expect(toast).toHaveBeenCalledWith({ - type: "error", - title: "Could not open image picker", - description: "picker failed", - }); -}); - -const imageUrl = `data:image/svg+xml,${encodeURIComponent( - '', -)}`; - -function addFiles(paths: string[]) { - state.entries = paths.map((path) => ({ path, kind: "file" })); - for (const path of paths) state.assets.set(path, Atom.make(AsyncResult.initial())); -} - -function fileRow(path: string) { - const row = [...document.querySelectorAll('[role="option"]')].find((element) => - element.textContent?.includes(path), - ); - expect(row, path).toBeDefined(); - return row!; -} - -async function publishUrl(path: string, url: string) { - await act(async () => { - appAtomRegistry.set( - state.assets.get(path)!, - AsyncResult.success({ relativeUrl: url, expiresAt: Date.now() + 60_000 }), - ); - }); -} - -// Decoding sets naturalWidth before the load event reaches React, so wait for both together. -async function expectPreview(row: HTMLElement) { - await expect - .poll(() => ({ - width: row.querySelector("img")?.naturalWidth, - fallback: row.querySelector("svg") !== null, - })) - .toEqual({ width: 8, fallback: false }); -} - -it("loads previews asynchronously and only acquires offscreen images after scrolling", async () => { - const paths = Array.from({ length: 40 }, (_, index) => `icons/icon-${index}.svg`); - addFiles(paths); - const { onSelect } = await renderPicker(); - const first = fileRow("icons/icon-0.svg"); - const last = fileRow("icons/icon-39.svg"); - - await expect.poll(() => state.createUrl.mock.calls.length).toBeGreaterThan(0); - expect(first.querySelector("img")).toBeNull(); - expect(first.querySelector("svg")).not.toBeNull(); - expect(last.getBoundingClientRect().top).toBeGreaterThan(window.innerHeight); - expect( - state.createUrl.mock.calls.some( - ([target]) => target.input.resource.path === "icons/icon-39.svg", - ), - ).toBe(false); - - await publishUrl("icons/icon-0.svg", imageUrl); - await expectPreview(first); - - await act(async () => last.scrollIntoView({ block: "center" })); - await expect - .poll(() => - state.createUrl.mock.calls.some( - ([target]) => target.input.resource.path === "icons/icon-39.svg", - ), - ) - .toBe(true); - await publishUrl("icons/icon-39.svg", imageUrl); - await expectPreview(last); - await act(async () => last.click()); - expect(onSelect).toHaveBeenCalledWith("icons/icon-39.svg"); -}); - -it("loads without IntersectionObserver and recovers a failed image when its URL changes", async () => { - vi.stubGlobal("IntersectionObserver", undefined); - addFiles(["icons/logo.svg"]); - await renderPicker(); - const row = fileRow("icons/logo.svg"); - - expect(state.createUrl).toHaveBeenCalledWith({ - environmentId: EnvironmentId.make("local"), - input: { - resource: { _tag: "draft-workspace-file", cwd: "/Users/me/project", path: "icons/logo.svg" }, - }, - }); - expect(row.querySelector("svg")).not.toBeNull(); - await publishUrl("icons/logo.svg", imageUrl); - await expectPreview(row); - - // Let Chromium reject the image bytes rather than dispatching a synthetic error. - await publishUrl("icons/logo.svg", "data:image/png;base64,broken"); - await expect.poll(() => row.querySelector("img")).toBeNull(); - expect(row.querySelector("svg")).not.toBeNull(); - - await publishUrl("icons/logo.svg", `${imageUrl}#renewed`); - await expectPreview(row); -}); - -it("keeps denied previews selectable with their file icon", async () => { - vi.stubGlobal("IntersectionObserver", undefined); - state.canReadFiles = false; - addFiles(["icons/private.svg"]); - const { onSelect } = await renderPicker(); - const row = fileRow("icons/private.svg"); - - await act(async () => row.click()); - expect(row.querySelector("img")).toBeNull(); - expect(row.querySelector("svg")).not.toBeNull(); - expect(state.createUrl).not.toHaveBeenCalled(); - expect(onSelect).toHaveBeenCalledWith("icons/private.svg"); -}); - -it("keeps the file icon until the image load is acknowledged", async () => { - vi.stubGlobal("IntersectionObserver", undefined); - addFiles(["icons/loading.svg"]); - await renderPicker(); - const row = fileRow("icons/loading.svg"); - // Hold the load notification so a fast data URL has the same UI state as a slow request. - const holdLoad = (event: Event) => { - if (event.target instanceof HTMLImageElement && row.contains(event.target)) { - event.stopImmediatePropagation(); - } - }; - document.addEventListener("load", holdLoad, true); - try { - await publishUrl("icons/loading.svg", imageUrl); - await expect.poll(() => row.querySelector("img")?.naturalWidth).toBe(8); - expect(row.querySelector("svg")).not.toBeNull(); - document.removeEventListener("load", holdLoad, true); - await publishUrl("icons/loading.svg", `${imageUrl}#loaded`); - await expectPreview(row); - } finally { - document.removeEventListener("load", holdLoad, true); - } -}); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx deleted file mode 100644 index eab8eb3bd885..000000000000 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.browser.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -import { deriveProjectIdentity } from "../../projectIdentity"; -import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -function button(name: string) { - return vi.waitFor(() => { - const match = [...document.querySelectorAll("button, [role='radio']")].find( - (element) => element.getAttribute("aria-label") === name || element.textContent === name, - ); - if (!match) throw new Error(`No button ${name}`); - return match; - }); -} - -// The modal's inert backdrop covers the unstyled popup, so Playwright pointer clicks land on it. -async function click(name: string) { - const target = await button(name); - await act(async () => target.click()); -} - -async function openPicker() { - const onSelect = vi.fn(); - const onOpenChange = vi.fn(); - await act(async () => - root.render( - , - ), - ); - return { onSelect, onOpenChange }; -} - -describe("ProjectIconPickerDialog", () => { - it("starts an automatic project on icons in its derived color and saves the picked icon", async () => { - const { onSelect, onOpenChange } = await openPicker(); - const { color } = deriveProjectIdentity("Test"); - expect((await button("Icons")).getAttribute("aria-pressed")).toBe("true"); - expect( - document.querySelector('[aria-label="Icon color"] [aria-pressed="true"]'), - ).not.toBeNull(); - - await userEvent.fill(searchBox(), "rocket"); - await click("Rocket"); - await click("Save icon"); - - expect(onSelect).toHaveBeenCalledExactlyOnceWith({ kind: "lucide", name: "rocket", color }); - expect(onOpenChange).toHaveBeenCalledWith(false); - }); - - it("saves a monogram only once the letters are letters or numbers", async () => { - const { onSelect } = await openPicker(); - await click("Monogram"); - const letters = await vi.waitFor(() => { - const input = document.querySelector("#project-monogram"); - if (!input) throw new Error("No monogram input"); - return input; - }); - - await userEvent.fill(letters, "t-3"); - await click("Save icon"); - expect(onSelect).not.toHaveBeenCalled(); - - await userEvent.fill(letters, "t3"); - await click("Save icon"); - expect(onSelect).toHaveBeenCalledExactlyOnceWith({ - kind: "monogram", - text: "T3", - color: deriveProjectIdentity("Test").color, - }); - }); -}); - -function searchBox() { - return document.querySelector('input[aria-label="Search Lucide icons"]')!; -} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts index 8a72b3510ceb..52d4fe6c2f39 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -1,23 +1,69 @@ import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; -import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +describe("project removal confirmation", () => { + const local = { + id: ProjectId.make("shared-id"), + environmentId: EnvironmentId.make("local"), + title: "Repo", + workspaceRoot: "/work/repo", + environmentLabel: "Local", + }; + const remote = { + ...local, + environmentId: EnvironmentId.make("remote"), + environmentLabel: "Remote", + }; -describe("projectGroupTitleNeedsUpdate", () => { - it("updates divergent member titles even when the next title is the derived group label", () => { - expect( - projectGroupTitleNeedsUpdate(["local-title", "remote-title"], "Repository name", true), - ).toBe(true); + it("limits a checkout's thread count to its environment and project", () => { + const plan = planProjectRemoval({ + members: [local], + groupMemberCount: 2, + hasOtherMembers: false, + displayName: "Repo", + threads: [ + { environmentId: local.environmentId, projectId: local.id }, + { environmentId: remote.environmentId, projectId: remote.id }, + ], + }); + expect(plan.projectThreads).toHaveLength(1); + expect(plan.confirmationMessage).toBe( + 'Remove checkout "Repo" and delete its 1 thread?\nPath: /work/repo\nEnvironment: Local\nThis permanently clears conversation history for those threads and any archived threads.\nOther entries in this grouped project are unaffected.\nThis action cannot be undone.', + ); }); - it("skips an untouched blur when the derived label differs from member titles", () => { - expect(projectGroupTitleNeedsUpdate(["repo-slug", "repo-slug"], "Repository Name", false)).toBe( - false, + it("keeps an environment-filtered group scoped to its selected checkout", () => { + const plan = planProjectRemoval({ + members: [remote], + groupMemberCount: 1, + hasOtherMembers: true, + displayName: "Repo", + threads: [], + }); + expect(plan.targetKind).toBe("checkout"); + expect(plan.confirmationMessage).toContain("Environment: Remote"); + expect(plan.confirmationMessage).toContain( + "Other entries in this grouped project are unaffected.", + ); + expect(plan.confirmationMessage).toContain( + "This permanently clears any archived conversation history.", ); }); - it("skips an update when every member already has the next title", () => { - expect(projectGroupTitleNeedsUpdate(["Shared name", "Shared name"], "Shared name", true)).toBe( - false, + // regression: #21409 project-wide removal must count every checkout's threads and keep files + it("states the full group count and preserves files when removing a project everywhere", () => { + const plan = planProjectRemoval({ + members: [local, remote], + groupMemberCount: 2, + hasOtherMembers: false, + displayName: "Repo", + threads: [], + }); + expect(plan.targetKind).toBe("project"); + expect(plan.confirmationMessage).toContain("This removes 2 grouped project entries."); + expect(plan.confirmationMessage).toContain( + "This removes only the project entries, not the files on disk.", ); }); }); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c757072b07dd..67bb5429389c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -10,6 +10,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { planProjectRemoval } from "@t3tools/client-runtime/state/projects"; import { notificationProjectKey } from "@t3tools/client-runtime/state/notification-rules"; import { AsyncResult } from "effect/reactivity"; import { type EnvironmentId, type ProjectIconOverride } from "@t3tools/contracts"; @@ -354,40 +355,15 @@ function ProjectDetail({ const api = readLocalApi(); if (!api) return; - const memberKeys = new Set(members.map(memberKey)); - const projectThreads = threads.filter((thread) => - memberKeys.has(`${thread.environmentId}:${thread.projectId}`), - ); - const isWholeGroup = members.length === group.memberProjects.length; - const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; - const singleMember = members.length === 1 ? members[0]! : null; - const targetLabel = singleMember?.title ?? group.displayName; + const { projectThreads, isWholeGroup, confirmationMessage } = planProjectRemoval({ + members, + groupMemberCount: group.memberProjects.length, + hasOtherMembers, + displayName: group.displayName, + threads, + }); const confirmed = await settlePromise(() => - api.dialogs.confirm( - [ - projectThreads.length > 0 - ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove ${targetKind} "${targetLabel}"?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - ...(projectThreads.length > 0 - ? [ - "This permanently clears conversation history for those threads and any archived threads.", - ] - : ["This permanently clears any archived conversation history."]), - isWholeGroup && !hasOtherMembers - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - "This action cannot be undone.", - ].join("\n"), - { variant: "destructive" }, - ), + api.dialogs.confirm(confirmationMessage, { variant: "destructive" }), ); if (confirmed._tag === "Failure" || !confirmed.value) return; if (checkProjectAccess(members, "Failed to remove project")) return; diff --git a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx index 2768f01498d6..00759dad856e 100644 --- a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx +++ b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx @@ -1,5 +1,3 @@ -"use client"; - import { PlusIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; diff --git a/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx b/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx deleted file mode 100644 index d0ca84fb88cf..000000000000 --- a/apps/web/src/components/settings/ProviderInstanceCard.browser.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; - -import { ProviderInstanceCard } from "./ProviderInstanceCard"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const driver = ProviderDriverKind.make("codex"); - -function provider(overrides: Partial): ServerProvider { - return { - instanceId: ProviderInstanceId.make("codex"), - driver, - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-08-27T12:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - ...overrides, - }; -} - -async function renderCard( - liveProvider: ServerProvider, - mode: ComponentProps["mode"], -) { - await act(async () => - root.render( - undefined} - hiddenModels={[]} - favoriteModels={[]} - modelOrder={[]} - onHiddenModelsChange={() => undefined} - onFavoriteModelsChange={() => undefined} - onToggleFavorite={() => undefined} - onModelOrderChange={() => undefined} - />, - ), - ); -} - -describe("ProviderInstanceCard", () => { - it("hides the account email until the user reveals it, and hides it again", async () => { - await renderCard( - provider({ auth: { status: "authenticated", email: "developer@example.com" } }), - "editor", - ); - expect(container.textContent).toContain("Authenticated as"); - expect(container.textContent).not.toContain("developer@example.com"); - - const toggle = container.querySelector( - 'button[aria-label="Toggle account email visibility"]', - )!; - await userEvent.click(toggle); - expect(toggle.textContent).toBe("developer@example.com"); - - await userEvent.click(toggle); - expect(container.textContent).not.toContain("developer@example.com"); - }); - - it.each(["list", "editor"] as const)( - "surfaces a failed probe message in the %s", - async (mode) => { - await renderCard( - provider({ - version: null, - status: "error", - auth: { status: "unknown" }, - message: - "Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink.", - }), - mode, - ); - expect(container.textContent).toContain("Unavailable"); - expect(container.textContent).toContain("is not a symlink"); - }, - ); -}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index e7a0826f7909..c681f7876517 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -4,8 +4,6 @@ import type { ServerProviderModel } from "@t3tools/contracts"; import { deriveProviderModelsForDisplay, nextProviderEnvironmentWithFieldValue, - providerEnvironmentWithoutNames, - readProviderEnvironmentVariable, } from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { @@ -38,46 +36,6 @@ describe("deriveProviderModelsForDisplay", () => { }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); - - it("prefers the entry's name and capabilities over the stale live custom row", () => { - const liveCapabilities = { optionDescriptors: [] }; - const customCapabilities = { - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select" as const, - options: [{ id: "high", label: "High", isDefault: true }], - currentValue: "high", - }, - ], - }; - const liveModels: ReadonlyArray = [ - { slug: "bare", name: "bare", isCustom: true, capabilities: liveCapabilities }, - { slug: "named", name: "named", isCustom: true, capabilities: liveCapabilities }, - ]; - - const display = deriveProviderModelsForDisplay({ - liveModels, - customModels: [ - { slug: "bare", name: "bare", capabilities: null }, - { slug: "named", name: "My Model", capabilities: customCapabilities }, - ], - }); - - expect(display[0]).toEqual({ - slug: "bare", - name: "bare", - isCustom: true, - capabilities: liveCapabilities, - }); - expect(display[1]).toEqual({ - slug: "named", - name: "My Model", - isCustom: true, - capabilities: customCapabilities, - }); - }); }); describe("provider environment helpers", () => { @@ -129,18 +87,4 @@ describe("provider environment helpers", () => { ), ).toEqual([{ name: "OPENAI_API_KEY", value: "new-key", sensitive: true }]); }); - - it("separates dedicated provider secrets from the generic environment table", () => { - const environment = [ - { name: "CURSOR_API_KEY", value: "cursor-key", sensitive: true }, - { name: "EXTRA_FLAG", value: "1", sensitive: false }, - ]; - - expect(readProviderEnvironmentVariable(environment, "CURSOR_API_KEY")?.value).toBe( - "cursor-key", - ); - expect(providerEnvironmentWithoutNames(environment, new Set(["CURSOR_API_KEY"]))).toEqual([ - { name: "EXTRA_FLAG", value: "1", sensitive: false }, - ]); - }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 1a6a0b885acf..bd8658671046 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Spinner } from "~/components/ui/spinner"; import { @@ -197,14 +195,14 @@ function ProviderAuthEmail(props: { readonly email: string | undefined }) { ); } -export function readProviderEnvironmentVariable( +function readProviderEnvironmentVariable( environment: ReadonlyArray | undefined, name: string, ): ProviderInstanceEnvironmentVariable | undefined { return environment?.find((variable) => variable.name === name); } -export function providerEnvironmentWithoutNames( +function providerEnvironmentWithoutNames( environment: ReadonlyArray | undefined, names: ReadonlySet, ): ReadonlyArray { diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts deleted file mode 100644 index bbc0c0ecac1e..000000000000 --- a/apps/web/src/components/settings/ProviderModelsSection.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import type { ServerProviderModel } from "@t3tools/contracts"; - -import { groupModelsForDisplay, nextHiddenModelsForBulkToggle } from "./ProviderModelsSection"; - -function model(slug: string, isCustom = false): ServerProviderModel { - return { slug, name: slug, isCustom, capabilities: null }; -} - -describe("groupModelsForDisplay", () => { - it("lists favorites first, then visible models in user order, then hidden ones", () => { - const models = [model("a"), model("b"), model("c"), model("d"), model("custom", true)]; - - const display = groupModelsForDisplay(models, { - favoriteModels: new Set(["c"]), - hiddenModels: new Set(["a", "custom"]), - modelOrder: ["d", "b"], - }); - - expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); - }); -}); - -describe("nextHiddenModelsForBulkToggle", () => { - it("hides every built-in model without hiding custom models", () => { - const models = [model("a"), model("b"), model("custom", true)]; - - expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]); - }); - - it("shows every built-in model while preserving unrelated hidden entries", () => { - const models = [model("a"), model("b"), model("custom", true)]; - - expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([ - "legacy", - "custom", - ]); - }); -}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index d0908f260386..e79eab8a80ca 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { @@ -60,9 +58,7 @@ function describeModelCapabilities(model: ServerProviderModel): string[] { return labels; } -export function groupModelsForDisplay< - T extends { readonly slug: string; readonly isCustom: boolean }, ->( +function groupModelsForDisplay( models: ReadonlyArray, options: { readonly favoriteModels: ReadonlySet; @@ -83,7 +79,7 @@ export function groupModelsForDisplay< ]; } -export function nextHiddenModelsForBulkToggle( +function nextHiddenModelsForBulkToggle( models: ReadonlyArray>, hiddenModels: ReadonlyArray, ): string[] { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 644cd7242b96..1d989bcdf66f 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -9,34 +9,6 @@ import { } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { - it("derives visible provider config fields from the client definition schema", () => { - const codex = providerClients.get(ProviderDriverKind.make("codex")); - - expect(codex).toBeDefined(); - expect(deriveProviderSettingsFields(codex!).map((field) => field.key)).toEqual([ - "binaryPath", - "homePath", - "shadowHomePath", - "launchArgs", - ]); - }); - - it("sources labels and descriptions from schema annotations", () => { - const opencode = providerClients.get(ProviderDriverKind.make("opencode")); - expect(opencode).toBeDefined(); - - const serverPassword = deriveProviderSettingsFields(opencode!).find( - (field) => field.key === "serverPassword", - ); - - expect(serverPassword).toMatchObject({ - label: "Server password", - description: "Stored separately on the host.", - control: "password", - secret: true, - }); - }); - it("keeps every masked field as a stored secret", () => { const settingsSchema = makeProviderSettingsSchema({ token: TrimmedString.pipe( @@ -49,77 +21,6 @@ describe("ProviderSettingsForm helpers", () => { ]); }); - it("uses a dedicated environment field instead of legacy Cursor CLI settings", () => { - const cursor = providerClients.get(ProviderDriverKind.make("cursor")); - - expect(cursor).toBeDefined(); - expect(deriveProviderSettingsFields(cursor!)).toEqual([]); - expect(cursor?.environmentFields).toEqual([ - { - name: "CURSOR_API_KEY", - label: "Cursor API key", - description: "Optional. Overrides browser sign-in for this provider.", - placeholder: "Paste API key", - sensitive: true, - }, - ]); - }); - - it("exposes ACP Registry as an instance-only configurable driver", () => { - const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); - - expect(acpRegistry).toBeDefined(); - expect(acpRegistry?.hasDefaultInstance).toBe(false); - expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ - "source", - "agentId", - "commandPath", - "authMethodId", - ]); - }); - - it("shows the local executable without registry identity or authentication fields", () => { - const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); - expect( - deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), - ).toEqual(["source", "commandPath"]); - }); - - it("derives a select control with its choices for the Antigravity sign-in method", () => { - const antigravity = providerClients.get(ProviderDriverKind.make("antigravity")); - expect(antigravity).toBeDefined(); - - const fields = deriveProviderSettingsFields(antigravity!); - expect(fields.map((field) => field.key)).toEqual([ - "authMethod", - "apiKey", - "gcpProject", - "gcpLocation", - "binaryPath", - ]); - const authMethod = fields.find((field) => field.key === "authMethod"); - expect(authMethod).toMatchObject({ control: "select", clearWhenEmpty: "omit" }); - expect(authMethod?.options?.map((option) => option.value)).toEqual([ - "oauth-personal", - "oauth-business", - "gemini-api-key", - "agent-platform", - ]); - expect(fields.find((field) => field.key === "apiKey")?.control).toBe("password"); - }); - - it("shows the auto-compaction threshold for Claude providers", () => { - const claude = providerClients.get(ProviderDriverKind.make("claudeAgent")); - expect(claude).toBeDefined(); - - expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ - "binaryPath", - "homePath", - "autoCompactWindow", - "launchArgs", - ]); - }); - it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = providerClients.get(ProviderDriverKind.make("opencode")); expect(opencode).toBeDefined(); @@ -138,38 +39,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("omits false boolean fields when clearWhenEmpty is omit", () => { - const next = nextProviderConfigWithFieldValue( - { forkOwned: 1, experimental: true }, - { - key: "experimental", - control: "switch", - label: "Experimental", - clearWhenEmpty: "omit", - defaultBooleanValue: false, - }, - false, - ); - - expect(next).toEqual({ forkOwned: 1 }); - }); - - it("omits true boolean fields when true is the default", () => { - const next = nextProviderConfigWithFieldValue( - { forkOwned: 1, experimental: false }, - { - key: "experimental", - control: "switch", - label: "Experimental", - clearWhenEmpty: "omit", - defaultBooleanValue: true, - }, - true, - ); - - expect(next).toEqual({ forkOwned: 1 }); - }); - it("stores false boolean fields when true is the default", () => { const next = nextProviderConfigWithFieldValue( undefined, @@ -185,19 +54,4 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("preserves false boolean fields when clearWhenEmpty is persist", () => { - const next = nextProviderConfigWithFieldValue( - undefined, - { - key: "experimental", - control: "switch", - label: "Experimental", - clearWhenEmpty: "persist", - }, - false, - ); - - expect(next).toEqual({ experimental: false }); - }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 7c6cb1a2f7fb..e1f41a5568a9 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 8ffadb25d71b..b86147639c04 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -8,12 +8,12 @@ import { type UnifiedSettings, } from "@t3tools/contracts"; import { AsyncResult, type Atom } from "effect/reactivity"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ providers: null as unknown as Atom.Writable>, @@ -187,43 +187,37 @@ function provider(): ServerProvider { }; } -let root: Root; -let container: HTMLDivElement; - const setProviders = (providers: ReadonlyArray) => act(async () => appAtomRegistry.set(state.providers, providers)); -async function render(options?: { +function panel(options?: { readonly readOnly?: boolean; readonly targetInstanceId?: ProviderInstanceId; }) { - await act(async () => - root.render( - -
- -
-
, - ), + return ( + +
+ +
+
); } +// The section header and the end of the provider list both offer Add provider. const button = (name: string) => page.getByRole("button", { name, exact: true }); const refreshButton = () => page.getByRole("button", { name: /^Refresh provider status/ }); -// The section header and the end of the provider list both offer Add provider. const addProvider = () => page.getByRole("button", { name: "Add provider" }).first(); const select = (name: string) => button(`Select ${name}`); const deleteButton = (id: string) => button(`Delete instance ${id}`); -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function setup() { await page.viewport(1280, 900); state.settings = DEFAULT_UNIFIED_SETTINGS; state.canManageProviders = true; @@ -241,115 +235,13 @@ beforeEach(async () => { state.uninstall.mockReset().mockResolvedValue(AsyncResult.success({})); state.acceptUrlAuth.mockReset().mockResolvedValue(AsyncResult.success({ accepted: true })); await setProviders([]); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("EnvironmentProviderSettings routing", () => { - it("shows Codex and Claude while hiding untouched disabled provider slots", async () => { - await render(); - await expect.element(select("Codex")).toBeVisible(); - await expect.element(select("Claude")).toBeVisible(); - for (const label of ["Cursor", "Grok", "Pi", "OpenCode", "Antigravity"]) { - await expect.element(select(label)).not.toBeInTheDocument(); - } - }); - - it("keeps explicitly configured providers visible when disabled", async () => { - state.settings = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [ProviderInstanceId.make("grok")]: { - driver: ProviderDriverKind.make("grok"), - enabled: false, - }, - }, - }; - await render(); - await expect.element(select("Grok")).toBeVisible(); - }); - - it("keeps legacy provider configuration visible when disabled", async () => { - state.settings = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [ProviderInstanceId.make("grok")]: { - driver: ProviderDriverKind.make("grok"), - enabled: false, - config: { binaryPath: "/custom/grok" }, - }, - }, - }; - await render(); - await expect.element(select("Grok")).toBeVisible(); - }); - - it("reads and writes settings on the selected environment", async () => { - await render(); - await expect.element(select("Codex")).toBeVisible(); - expect(new Set(state.readEnvironmentIds)).toEqual(new Set([environmentId])); - expect(new Set(state.updateEnvironmentIds)).toEqual(new Set([environmentId])); - expect(new Set(state.mutationEnvironmentIds)).toEqual(new Set([environmentId])); - }); - - it("routes refresh and provider update commands to the selected environment", async () => { - await setProviders([provider()]); - await render(); - - await userEvent.click(refreshButton()); - expect(state.refresh).toHaveBeenCalledWith({ - environmentId, - input: { refreshModels: true }, - }); - - await userEvent.click(page.getByRole("button", { name: /view details/ }).last()); - await userEvent.click(button("Update now")); - expect(state.updateProvider).toHaveBeenCalledWith({ - environmentId, - input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, - }); - }); - - it("opens the requested provider instance instead of the first provider", async () => { - state.settings = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [customId]: { - driver: ProviderDriverKind.make("codex"), - enabled: true, - displayName: "Work", - }, - }, - }; - await setProviders([provider()]); - await render({ targetInstanceId: customId }); - await expect.element(deleteButton(customId)).toBeVisible(); - }); - - it("saves model favorites and visibility on the selected environment", async () => { - await setProviders([provider()]); - await render(); - - await userEvent.click(button("Add Chosen to favorites")); - expect(state.toggleFavorite).toHaveBeenCalledExactlyOnceWith(codexId, "chosen"); - - await userEvent.click(page.getByRole("switch", { name: "Show Chosen in the model picker" })); - expect(state.updateModelPreferences).toHaveBeenCalledExactlyOnceWith({ - setModelsHidden: [{ provider: codexId, model: "chosen", hidden: true }], - }); - expect(state.updateSettings).not.toHaveBeenCalled(); - }); - it("does not substitute another account when the requested instance was removed", async () => { + await setup(); await setProviders([provider()]); - await render({ targetInstanceId: customId }); + await using _screen = await render(panel({ targetInstanceId: customId })); await expect .element(page.getByText("This provider instance is no longer available on this device.")) .toBeVisible(); @@ -357,6 +249,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("keeps provider selection available while write controls are read only", async () => { + await setup(); state.canWriteSettings = false; state.settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -369,19 +262,20 @@ describe("EnvironmentProviderSettings routing", () => { }, }; await setProviders([provider()]); - await render({ readOnly: true }); + await using screen = await render(panel({ readOnly: true })); await expect.element(page.getByRole("heading", { name: "Limited permissions" })).toBeVisible(); await userEvent.click(select("Work")); await expect.element(deleteButton(customId)).toBeInTheDocument(); - expect(container.querySelector("[inert]")).not.toBeNull(); + expect(screen.container.querySelector("[inert]")).not.toBeNull(); await expect.element(refreshButton()).toBeVisible(); await expect.element(addProvider()).not.toBeInTheDocument(); }); it("keeps the editable layout interactive when not read only", async () => { + await setup(); await setProviders([provider()]); - await render(); + await using _screen = await render(panel()); await expect.element(page.getByText("Limited permissions")).not.toBeInTheDocument(); await expect.element(refreshButton()).toBeVisible(); await expect.element(addProvider()).toBeVisible(); @@ -389,28 +283,31 @@ describe("EnvironmentProviderSettings routing", () => { }); it("removes an open add-instance dialog when the provider grant is revoked", async () => { - await render(); + await setup(); + await using screen = await render(panel()); await userEvent.click(addProvider()); await expect.element(page.getByRole("dialog")).toBeVisible(); state.canManageProviders = false; - await render({ readOnly: true }); + await screen.rerender(panel({ readOnly: true })); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(state.updateSettings).not.toHaveBeenCalled(); }); it("keeps Advanced visible when search targets the provider health interval", async () => { - await render(); + await setup(); + await using screen = await render(panel()); await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); - expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + expect(screen.container.querySelector("#provider-health-check-interval")).not.toBeNull(); state.searchTargetId = "provider-health-check-interval"; - await render(); + await screen.rerender(panel()); await expect.element(page.getByText("Advanced", { exact: true })).toBeVisible(); - expect(container.querySelector("#provider-health-check-interval")).not.toBeNull(); + expect(screen.container.querySelector("#provider-health-check-interval")).not.toBeNull(); }); it("deletes provider configuration without erasing shared preferences", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -426,7 +323,7 @@ describe("EnvironmentProviderSettings routing", () => { }, favorites: [{ provider: customId, model: "favorite" }], }; - await render(); + await using _screen = await render(panel()); await userEvent.click(select("Work")); await userEvent.click(deleteButton(customId)); expect(state.mutateProviderInstance).toHaveBeenLastCalledWith({ @@ -450,9 +347,10 @@ describe("EnvironmentProviderSettings routing", () => { }); it("deletes an enabled built-in provider that has no explicit instance", async () => { + await setup(); // Codex and Claude ship enabled at factory defaults: a fresh install shows // them with no providerInstances entry, and deleting must hide them too. - await render(); + await using screen = await render(panel()); await userEvent.click(select("Codex")); await userEvent.click(deleteButton(codexId)); const [mutation] = state.mutateProviderInstance.mock.lastCall ?? []; @@ -464,13 +362,14 @@ describe("EnvironmentProviderSettings routing", () => { state.settings = { ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { [codexId]: mutation.instance }, + providerInstances: { [codexId]: mutation!.instance }, }; - await render(); + await screen.rerender(panel()); await expect.element(select("Codex")).not.toBeInTheDocument(); }); it("removes the entry when deleting a provider that is off by default", async () => { + await setup(); const grokId = ProviderInstanceId.make("grok"); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -478,7 +377,7 @@ describe("EnvironmentProviderSettings routing", () => { [grokId]: { driver: ProviderDriverKind.make("grok"), enabled: false }, }, }; - await render(); + await using _screen = await render(panel()); await userEvent.click(select("Grok")); await userEvent.click(deleteButton(grokId)); expect(state.mutateProviderInstance.mock.lastCall?.[0]).toEqual({ @@ -488,6 +387,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("updates one provider instance without sending a stale whole map", async () => { + await setup(); state.settings = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -498,7 +398,7 @@ describe("EnvironmentProviderSettings routing", () => { }, }, }; - await render(); + await using _screen = await render(panel()); await userEvent.click(page.getByRole("switch", { name: "Enable Work" }).first()); expect(state.mutateProviderInstance).toHaveBeenCalledWith( @@ -511,61 +411,8 @@ describe("EnvironmentProviderSettings routing", () => { ); }); - it("lets the server decide managed ACP cleanup after an atomic delete", async () => { - const firstId = ProviderInstanceId.make("acpRegistry_kilo_one"); - const secondId = ProviderInstanceId.make("acpRegistry_kilo_two"); - const registryInstance = (displayName: string) => ({ - driver: ProviderDriverKind.make("acpRegistry"), - enabled: true, - displayName, - config: { agentId: "kilo" }, - }); - state.settings = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [firstId]: registryInstance("Kilo one"), - [secondId]: registryInstance("Kilo two"), - }, - }; - await render(); - await userEvent.click(select("Kilo one")); - await userEvent.click(deleteButton(firstId)); - - expect(state.mutateProviderInstance).toHaveBeenCalledWith({ - operation: "remove", - instanceId: firstId, - }); - await vi.waitFor(() => - expect(state.uninstall).toHaveBeenCalledWith({ environmentId, input: { agentId: "kilo" } }), - ); - }); - - it("keeps the signed-in ACP account visible when login methods are no longer advertised", async () => { - const instanceId = ProviderInstanceId.make("acpRegistry_devin"); - state.settings = { - ...DEFAULT_UNIFIED_SETTINGS, - providerInstances: { - [instanceId]: { - driver: ProviderDriverKind.make("acpRegistry"), - enabled: true, - config: { agentId: "devin" }, - }, - }, - }; - await setProviders([ - { - ...provider(), - instanceId, - driver: ProviderDriverKind.make("acpRegistry"), - auth: { status: "authenticated", canLogout: false }, - setup: { canAuthenticate: false, canInstall: false }, - }, - ]); - await render({ targetInstanceId: instanceId }); - await expect.element(page.getByRole("heading", { name: "Account" })).toBeVisible(); - }); - it("routes explicit ACP browser authentication consent to the selected environment", async () => { + await setup(); const instanceId = ProviderInstanceId.make("acpRegistry_antigravity"); const action = { elicitationId: "google-login-1", @@ -590,7 +437,7 @@ describe("EnvironmentProviderSettings routing", () => { auth: { status: "unauthenticated", action }, }, ]); - await render({ targetInstanceId: instanceId }); + await using _screen = await render(panel({ targetInstanceId: instanceId })); const link = page.getByRole("link", { name: "Continue authentication" }); link.element().addEventListener("click", (event) => event.preventDefault()); await userEvent.click(link); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index c3c3fc06835b..2be9da61694a 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -1,6 +1,5 @@ import { AuthProvidersManageScope, - EnvironmentId, ProjectId, ProviderInstanceId, type ServerSettings, @@ -8,180 +7,12 @@ import { import { describe, expect, it } from "vite-plus/test"; import { - buildProviderEnvironmentOptions, buildProviderInstanceOverridePatch, - classifyProviderEnvironmentAccess, - isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, - resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; -const primaryId = EnvironmentId.make("primary"); -const relayId = EnvironmentId.make("relay"); -const sshId = EnvironmentId.make("ssh"); - -const environments = [ - { environmentId: sshId, label: "Zulu SSH" }, - { environmentId: relayId, label: "Alpha Relay" }, - { environmentId: primaryId, label: "This device" }, -] as const; - -describe("provider environment selection", () => { - it("requires a connected environment with server config for searchable provider settings", () => { - expect( - isProviderSettingsEnvironmentAvailable({ - connectionPhase: "connected", - hasServerConfig: true, - }), - ).toBe(true); - expect( - isProviderSettingsEnvironmentAvailable({ - connectionPhase: "reconnecting", - hasServerConfig: true, - }), - ).toBe(false); - expect( - isProviderSettingsEnvironmentAvailable({ - connectionPhase: "connected", - hasServerConfig: false, - }), - ).toBe(false); - }); - - it("sorts the primary environment first and the rest by label", () => { - expect( - buildProviderEnvironmentOptions(environments, primaryId).map( - (environment) => environment.environmentId, - ), - ).toEqual([primaryId, relayId, sshId]); - }); - - it("keeps a valid selection, then falls back to primary or the first environment", () => { - const options = buildProviderEnvironmentOptions(environments, primaryId); - - expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); - expect( - resolveSelectedProviderEnvironmentId( - options.filter((environment) => environment.environmentId !== sshId), - sshId, - primaryId, - ), - ).toBe(primaryId); - expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( - relayId, - ); - expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); - }); -}); - -describe("provider environment access", () => { - it("allows connected environments with config and operate access", () => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase: "connected", - hasServerConfig: true, - operateAccess: "granted", - }), - ).toEqual({ kind: "editable" }); - }); - - it("waits for config before exposing controls", () => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase: "connected", - hasServerConfig: false, - operateAccess: "granted", - }), - ).toEqual({ kind: "loading", reason: "config" }); - }); - - it("waits for unresolved operate access instead of assuming it is editable", () => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase: "connected", - hasServerConfig: true, - operateAccess: "pending", - }), - ).toEqual({ kind: "loading", reason: "permissions" }); - }); - - it("represents known missing operate access as read only", () => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase: "connected", - hasServerConfig: true, - operateAccess: "denied", - }), - ).toEqual({ kind: "read-only" }); - }); - - it.each(["available", "offline", "connecting", "reconnecting"] as const)( - "keeps %s environments unavailable", - (connectionPhase) => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase, - hasServerConfig: true, - operateAccess: "granted", - }), - ).toEqual({ kind: "unavailable" }); - }, - ); - - it("separates connection errors from other unavailable states", () => { - expect( - classifyProviderEnvironmentAccess({ - connectionPhase: "error", - hasServerConfig: true, - operateAccess: "granted", - }), - ).toEqual({ kind: "error" }); - }); -}); - describe("primary operate access", () => { - const authenticated = { - authenticated: true as const, - scopes: [AuthProvidersManageScope], - }; - - it("keeps cached session data authoritative while SWR revalidates", () => { - expect( - resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: authenticated, - isPending: true, - hasError: false, - }), - ).toBe("granted"); - }); - - it("reports pending only before any session has resolved", () => { - expect( - resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: null, - isPending: true, - hasError: false, - }), - ).toBe("pending"); - }); - - it("denies writes when the session fetch fails", () => { - expect( - resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: null, - isPending: false, - hasError: true, - }), - ).toBe("denied"); - }); - it("denies unauthenticated sessions and sessions without the operate scope", () => { expect( resolvePrimaryOperateAccess({ @@ -211,33 +42,11 @@ describe("primary operate access", () => { }), ).toBe("denied"); }); - - it("waits for explicit grants on desktop and remote environments", () => { - expect( - resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: true, - session: null, - isPending: true, - hasError: false, - }), - ).toBe("pending"); - expect( - resolvePrimaryOperateAccess({ - isPrimary: false, - hasDesktopBridge: false, - session: null, - isPending: true, - hasError: false, - }), - ).toBe("pending"); - }); }); describe("buildProviderInstanceOverridePatch", () => { const projectId = ProjectId.make("project-a"); const instanceId = ProviderInstanceId.make("codex_work"); - const otherInstanceId = ProviderInstanceId.make("claudeAgent_personal"); it("writes the same choice to every checkout of the project", () => { const siblingId = ProjectId.make("project-a-worktree"); @@ -258,37 +67,6 @@ describe("buildProviderInstanceOverridePatch", () => { }); }); - it("sets an override when the project has none yet", () => { - const settings: Pick = { - projectSettingsOverrides: {}, - }; - - expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, - }); - }); - - it("adds an instance override alongside the project's other overrides", () => { - const settings: Pick = { - projectSettingsOverrides: { - [projectId]: { - newWorktreesStartFromOrigin: true, - providerInstances: { [otherInstanceId]: { enabled: false } }, - }, - }, - }; - - expect(buildProviderInstanceOverridePatch(settings, [projectId], instanceId, true)).toEqual({ - [projectId]: { - newWorktreesStartFromOrigin: true, - providerInstances: { - [otherInstanceId]: { enabled: false }, - [instanceId]: { enabled: true }, - }, - }, - }); - }); - it("resets the only override by clearing the whole project entry", () => { const settings: Pick = { projectSettingsOverrides: { @@ -301,29 +79,6 @@ describe("buildProviderInstanceOverridePatch", () => { ).toEqual({ [projectId]: null }); }); - it("resets one instance while another instance's override and other keys survive", () => { - const settings: Pick = { - projectSettingsOverrides: { - [projectId]: { - newWorktreesStartFromOrigin: true, - providerInstances: { - [instanceId]: { enabled: true }, - [otherInstanceId]: { enabled: false }, - }, - }, - }, - }; - - expect( - buildProviderInstanceOverridePatch(settings, [projectId], instanceId, undefined), - ).toEqual({ - [projectId]: { - newWorktreesStartFromOrigin: true, - providerInstances: { [otherInstanceId]: { enabled: false } }, - }, - }); - }); - it("sends providerInstances: {} explicitly when clearing the last instance override leaves other keys", () => { const settings: Pick = { projectSettingsOverrides: { @@ -389,30 +144,4 @@ describe("remote operate access", () => { }), ).toBe("denied"); }); - - it("reports pending before the first session resolve, then keeps cached data", () => { - expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( - "pending", - ); - expect( - resolveRemoteOperateAccess({ - session: { authenticated: true, scopes: [AuthProvidersManageScope] }, - isPending: true, - hasError: false, - }), - ).toBe("granted"); - }); - - it("denies writes when the session fetch fails or scopes are missing", () => { - expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( - "denied", - ); - expect( - resolveRemoteOperateAccess({ - session: { authenticated: true }, - isPending: false, - hasError: false, - }), - ).toBe("denied"); - }); }); diff --git a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx index e6d5ae1c01f4..9958394f5884 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.browser.test.tsx @@ -6,10 +6,10 @@ import { type ProviderInstallState, type ServerProvider, } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { page, userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; const setup = vi.hoisted(() => ({ auth: null as ProviderAuthState | null, @@ -111,138 +111,106 @@ function authState(patch: Partial = {}): ProviderAuthState { }; } -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; +interface SectionOptions { + readOnly?: boolean; + provider?: ServerProvider; + enabled?: boolean; + binaryPath?: string; } -let root: Root; -let container: HTMLDivElement; - -function renderSetup( - options: { - readOnly?: boolean; - provider?: ServerProvider; - enabled?: boolean; - binaryPath?: string; - } = {}, -) { - return act(async () => - root.render( - , - ), +function section(options: SectionOptions = {}) { + return ( + ); } -function button(label: string) { - return ( +async function renderSetup(options: SectionOptions = {}) { + const screen = await render(section(options)); + const { container } = screen; + const button = (label: string) => [...container.querySelectorAll("button")].find( (element) => element.textContent === label || element.getAttribute("aria-label") === label, - ) ?? null - ); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).not.toBeNull(); - expect(target!.disabled, `${label} disabled`).toBe(false); - await act(async () => target!.click()); + ) ?? null; + return Object.assign(screen, { + button, + async click(label: string) { + const target = button(label); + expect(target, `button ${label}`).not.toBeNull(); + expect(target!.disabled, `${label} disabled`).toBe(false); + await act(async () => target!.click()); + }, + occurrences: (text: string) => (container.textContent ?? "").split(text).length - 1, + callbackInput: () => + container.querySelector(`[id="provider-callback-${instanceId}"]`)!, + }); } -function occurrences(text: string) { - return (container.textContent ?? "").split(text).length - 1; + +function resetSetup() { + vi.clearAllMocks(); + setup.auth = authState(); + setup.installation = { + driver: ProviderDriverKind.make("antigravity"), + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: null, + version: null, + installedVersion: null, + canRemove: false, + message: null, + }; + for (const command of [ + setup.startAuth, + setup.completeAuth, + setup.cancelAuth, + setup.logoutAuth, + setup.startInstall, + setup.cancelInstall, + setup.removeInstall, + ]) { + command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + } + setup.confirm.mockReset().mockResolvedValue(false); } -const callbackInput = () => - container.querySelector(`[id="provider-callback-${instanceId}"]`)!; describe("Antigravity setup", () => { - beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - setup.auth = authState(); - setup.installation = { - driver: ProviderDriverKind.make("antigravity"), - operationId: null, - phase: "idle", - downloadedBytes: 0, - totalBytes: null, - version: null, - installedVersion: null, - canRemove: false, - message: null, - }; - for (const command of [ - setup.startAuth, - setup.completeAuth, - setup.cancelAuth, - setup.logoutAuth, - setup.startInstall, - setup.cancelInstall, - setup.removeInstall, - ]) { - command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - } - setup.confirm.mockReset().mockResolvedValue(false); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); - }); - it("waits for verified auth after submitting a callback to the selected environment", async () => { + resetSetup(); const callbackUrl = "http://127.0.0.1:5555/?state=test-only&code=test-only"; - await renderSetup(); - await act(async () => userEvent.fill(callbackInput(), callbackUrl)); - await click("Continue"); + await using screen = await renderSetup(); + await userEvent.fill(screen.callbackInput(), callbackUrl); + await screen.click("Continue"); expect(setup.completeAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId, flowId: "flow-1", callbackUrl }, }); - expect(occurrences("Signed in.")).toBe(0); - expect(callbackInput().value).toBe(""); + expect(screen.occurrences("Signed in.")).toBe(0); + expect(screen.callbackInput().value).toBe(""); setup.auth = authState({ phase: "verifying", authorizationUrl: null }); await publish(); - expect(occurrences("Signed in.")).toBe(0); + expect(screen.occurrences("Signed in.")).toBe(0); setup.auth = authState({ phase: "succeeded", authorizationUrl: null }); - await renderSetup({ - provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, - }); - expect(occurrences("Signed in.")).toBe(1); - }); - - it("offers sign-in again when credentials expire after a completed auth flow", async () => { - setup.auth = authState({ - phase: "succeeded", - authorizationUrl: null, - message: "Google sign-in complete.", - }); - await renderSetup({ - provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, - }); - await renderSetup(); - expect(button("Sign in")).not.toBeNull(); - expect(occurrences("Signed in.")).toBe(0); - expect(occurrences("Google sign-in complete.")).toBe(0); + await screen.rerender( + section({ + provider: { ...provider, status: "ready", auth: { status: "authenticated" } }, + }), + ); + expect(screen.occurrences("Signed in.")).toBe(1); }); it("does not send a callback left over from a replaced sign-in flow", async () => { + resetSetup(); const interaction = { type: "browser" as const, id: "same-interaction", @@ -251,153 +219,33 @@ describe("Antigravity setup", () => { acceptsCallback: true, }; setup.auth = authState({ interaction }); - await renderSetup(); - await act(async () => - userEvent.fill(callbackInput(), "http://127.0.0.1:5555/?state=old-flow&code=test-only"), + await using screen = await renderSetup(); + await userEvent.fill( + screen.callbackInput(), + "http://127.0.0.1:5555/?state=old-flow&code=test-only", ); setup.auth = authState({ flowId: "flow-2", interaction }); await publish(); - expect(callbackInput().value).toBe(""); - await act(async () => callbackInput().form!.requestSubmit()); + expect(screen.callbackInput().value).toBe(""); + await act(async () => screen.callbackInput().form!.requestSubmit()); expect(setup.completeAuth).not.toHaveBeenCalled(); expect(setup.authState).toHaveBeenLastCalledWith({ environmentId, input: { instanceId } }); }); - it("coalesces repeated sign-in clicks while start is pending", async () => { - setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); - const pending = deferred<{ _tag: "Success"; value: undefined }>(); - setup.startAuth.mockReturnValueOnce(pending.promise); - await renderSetup(); - const signIn = button("Sign in")!; - // Both clicks land before React re-renders the button as disabled. - await act(async () => { - signIn.click(); - signIn.click(); - }); - - expect(setup.startAuth).toHaveBeenCalledTimes(1); - expect(setup.startAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); - await act(async () => pending.resolve({ _tag: "Success", value: undefined })); - }); - - it("shows a repeated runtime status message only once", async () => { - setup.installation = { - ...setup.installation!, - operationId: "install-1", - phase: "verifying", - message: "Checking the downloaded runtime.", - }; - - await renderSetup(); - expect(occurrences("Checking the downloaded runtime.")).toBe(1); - }); - - it("exposes download progress as a bounded progressbar only while the total is known", async () => { - const downloading = (downloadedBytes: number, totalBytes: number | null) => { - setup.installation = { - ...setup.installation!, - operationId: "install-1", - phase: "downloading", - downloadedBytes, - totalBytes, - }; - return publish(); - }; - const progressbar = page.getByRole("progressbar", { name: "Antigravity download" }); - await renderSetup(); - - await downloading(40_000_000, 100_000_000); - await expect.element(progressbar).toHaveValue(40_000_000); - // The wire contract does not cap downloaded bytes at the advertised total. - await downloading(110_000_000, 100_000_000); - await expect.element(progressbar).toHaveValue(100_000_000); - await downloading(40_000_000, null); - expect(progressbar.query()).toBeNull(); - }); - - it("removes an owned damaged runtime only after confirmation", async () => { - setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); - setup.installation = { - ...setup.installation!, - phase: "failed", - canRemove: true, - installedVersion: null, - }; - await renderSetup(); - await click("Remove downloaded runtime"); - expect(setup.confirm).toHaveBeenCalledOnce(); - expect(setup.removeInstall).not.toHaveBeenCalled(); - - setup.confirm.mockResolvedValue(true); - await click("Remove downloaded runtime"); - expect(setup.removeInstall).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); - }); - - it.each([true, false])( - "can sign out a verified account when its instance is enabled=%s", - async (enabled) => { - setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); - setup.confirm.mockResolvedValue(true); - await renderSetup({ - enabled, - provider: { - ...provider, - enabled, - installed: false, - status: enabled ? "warning" : "disabled", - auth: { status: "authenticated" }, - }, - }); - await click("Sign out"); - expect(setup.logoutAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); - }, - ); - - it.each(["unauthenticated", "unknown"] as const)( - "does not offer sign-out for an unverified %s account", - async (status) => { - setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); - await renderSetup({ - provider: { ...provider, auth: { status, canLogout: true } }, - }); - expect(button("Sign in")).not.toBeNull(); - expect(button("Sign out")).toBeNull(); - expect(button("Change account")).toBeNull(); - }, - ); - - it("offers account actions after verified login when discovery cannot identify auth", async () => { - setup.auth = authState({ phase: "succeeded", flowId: null, authorizationUrl: null }); - await renderSetup({ provider: { ...provider, auth: { status: "unknown" } } }); - expect(button("Change account")).not.toBeNull(); - expect(button("Sign out")).not.toBeNull(); - expect(button("Sign in")).toBeNull(); - }); - it("does not let a shared managed install hide an invalid custom binary path", async () => { + resetSetup(); setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.installation = { ...setup.installation!, installedVersion: "test-version", canRemove: true, }; - await renderSetup({ + await using screen = await renderSetup({ provider: { ...provider, installed: false }, binaryPath: "/missing/antigravity", }); - expect(button("Sign in")?.disabled).toBe(true); + expect(screen.button("Sign in")?.disabled).toBe(true); expect(setup.startAuth).not.toHaveBeenCalled(); }); - - it.each(["read-only", "older-server"] as const)( - "does not open private setup subscriptions for a %s view", - async (mode) => { - const { setup: _setup, ...olderProvider } = provider; - await renderSetup(mode === "read-only" ? { readOnly: true } : { provider: olderProvider }); - expect(setup.authState).not.toHaveBeenCalled(); - expect(setup.installState).not.toHaveBeenCalled(); - expect(setup.startAuth).not.toHaveBeenCalled(); - }, - ); }); diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts index e82820f45fdd..938bc5e4a8dc 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts @@ -35,43 +35,6 @@ const process = (pid: number, ppid: number, depth: number): ResourceTelemetryPro lastSeenAt: DateTime.makeUnsafe(0), }); -describe("shouldShowResourceMonitorRetry", () => { - it("allows retry when the initial telemetry request fails before a snapshot", () => { - expect( - shouldShowResourceMonitorRetry({ - nativeStatus: null, - error: "Resource monitor is unavailable.", - }), - ).toBe(true); - }); - - it("does not show retry for an initial load without an error or a healthy snapshot", () => { - expect(shouldShowResourceMonitorRetry({ nativeStatus: null, error: null })).toBe(false); - expect(shouldShowResourceMonitorRetry({ nativeStatus: "healthy", error: null })).toBe(false); - }); -}); - -describe("resourceHistoryBarHeight", () => { - it("renders an exactly zero sample with no visible bar", () => { - expect(resourceHistoryBarHeight({ value: 0, max: 100, minimumVisiblePercent: 2 })).toBe(0); - }); - - it("keeps nonzero samples visible without changing proportional heights", () => { - expect(resourceHistoryBarHeight({ value: 0.5, max: 100, minimumVisiblePercent: 2 })).toBe(2); - expect(resourceHistoryBarHeight({ value: 50, max: 100, minimumVisiblePercent: 2 })).toBe(50); - }); -}); - -describe("resourceHistoryCpuScaleMax", () => { - it("scales average bars independently of brief peak spikes", () => { - const buckets = [ - { avgCpuPercent: 1, maxCpuPercent: 100 }, - { avgCpuPercent: 8, maxCpuPercent: 8 }, - ]; - expect(resourceHistoryCpuScaleMax(buckets)).toBe(8); - }); -}); - describe("visibleResourceTelemetryProcesses", () => { it("hides descendants by parentage even when rows are not depth-first", () => { const processes = [ diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 045625a4394c..4df156be8ca0 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -22,13 +22,20 @@ import type { ScheduledTaskId, ScheduledTaskSchedule, ScheduledTaskUpsertInput, - ScheduledTaskWebhookDeliveryOutcome, ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; -import { DEFAULT_WEBHOOK_PROMPT } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { + DEFAULT_WEBHOOK_PROMPT, + emptyWebhookFilterDraft, + parseWebhookFilterDrafts, + removeWebhookFilterDraft, + WEBHOOK_FILTER_OPS, + type WebhookFilterDraft, +} from "@t3tools/client-runtime/scheduled-task-webhook"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, + MAX_WEBHOOK_FILTERS, MIN_SCHEDULED_TASK_INTERVAL_MS, ProviderInstanceId, resolveEnvironmentMachineKind, @@ -77,6 +84,8 @@ import { SettingsGroup } from "./SettingsGroup"; import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; import { WEBHOOK_SIGNATURE_DEFAULTS, + webhookDraftErrors, + webhookDeliveryLabel, matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, @@ -150,8 +159,106 @@ const EMPTY_DRAFT: DraftState = { ...WEBHOOK_SIGNATURE_DEFAULTS, signatureSecret: "", maxDeliveryAgeMinutes: "", + replayKey: "", + coalesceKey: "", + coalesceWindowSeconds: "", + filters: [], + savedFilters: [], }; +function WebhookFiltersField({ + filters, + onChange, +}: { + filters: ReadonlyArray; + onChange: (filters: ReadonlyArray) => void; +}) { + const update = (index: number, patch: Partial) => + onChange(filters.map((filter, at) => (at === index ? { ...filter, ...patch } : filter))); + return ( +
+
+
+ +

+ Run only when every filter matches. Other requests are logged as filtered. Separate “is + one of” values with commas; a value can't contain a comma. +

+
+ +
+ {filters.map((filter, index) => ( + // Always stacked: the dialog stays narrow on wide windows, so a single line + // would cut off typical paths and values. The grid wrappers size each control + // without restyling it; a grid item stretches to its minmax(0, 1fr) track. +
+
+
+ update(index, { path: event.target.value })} + /> +
+ +
+
+ + {filter.op === "exists" ? null : ( +
+ update(index, { value: event.target.value })} + /> +
+ )} +
+
+ ))} +
+ ); +} + function Field({ label, hint, @@ -236,21 +343,19 @@ export function ScheduledRelativeLabel(props: { value: string | null }) { return relativeLabel(props.value, Date.parse(`${nowMinute}:00.000Z`)); } -const DELIVERY_OUTCOME_LABELS: Record = { - accepted: "Ran", - dispatch_failed: "Run failed", - rejected_signature: "Bad signature", - disabled: "Task paused", - rate_limited: "Rate limited", - expired: "Too old", -}; - -function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) { +function deliveryOutcomeVariant(outcome: string) { if (outcome === "accepted") return "success"; - if (outcome === "disabled" || outcome === "rate_limited" || outcome === "expired") { + if ( + outcome === "duplicate" || + outcome === "coalesced" || + outcome === "disabled" || + outcome === "rate_limited" || + outcome === "expired" + ) { return "warning"; } - return "error"; + if (outcome === "dispatch_failed" || outcome === "rejected_signature") return "error"; + return "outline"; } function statusVariant(status: ScheduledTask["lastRunStatus"]) { @@ -386,7 +491,6 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, - timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -428,7 +532,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks, timeZone }) => { + {populated.map(({ environment, tasks }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -458,7 +562,6 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -574,7 +677,6 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -588,12 +690,10 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, - timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; - readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -638,7 +738,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule, timeZone)} ·{" "} + {scheduleLabel(task.schedule)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -724,7 +824,10 @@ function WebhookDeliveriesDialog({ readonly onClose: () => void; }) { const deliveriesQuery = useEnvironmentQuery( - serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }), + serverEnvironment.scheduledTaskWebhookDeliveries({ + environmentId, + input: { id: task.id, acceptAnyOutcome: true }, + }), ); const [selectedId, setSelectedId] = useState( null, @@ -734,7 +837,7 @@ function WebhookDeliveriesDialog({ ? null : serverEnvironment.scheduledTaskWebhookDelivery({ environmentId, - input: { id: task.id, deliveryId: selectedId }, + input: { id: task.id, deliveryId: selectedId, acceptAnyOutcome: true }, }), ); const deliveries = deliveriesQuery.data?.deliveries ?? null; @@ -762,8 +865,12 @@ function WebhookDeliveriesDialog({ ) : selected ? (
- - {DELIVERY_OUTCOME_LABELS[selected.outcome]} + + {webhookDeliveryLabel(selected)} {selected.method} · @@ -772,9 +879,24 @@ function WebhookDeliveriesDialog({ Signature verified ) : null}
- {selected.error ?

{selected.error}

: null} - - {selected.renderedPrompt ?? "No run was started for this request."} + {selected.error ? ( +

+ {selected.error} +

+ ) : null} + + {selected.renderedPrompt ?? + (selected.pendingWindow + ? "Run scheduled when the window closes; no run started yet." + : "No run was started for this request.")} {selected.missingFields.length > 0 ? (

@@ -808,13 +930,20 @@ function WebhookDeliveriesDialog({ className="flex w-full flex-wrap items-center gap-2 py-2 text-left text-sm hover:bg-accent/40" onClick={() => setSelectedId(delivery.id)} > - - {DELIVERY_OUTCOME_LABELS[delivery.outcome]} + + {webhookDeliveryLabel(delivery)} {delivery.method} + {delivery.outcome === "filtered" && delivery.error ? ( + {delivery.error} + ) : null} {delivery.missingFields.length > 0 ? ( {delivery.missingFields.length} empty placeholder @@ -1030,7 +1159,6 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); - const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1091,12 +1219,14 @@ function ScheduledTaskEditorForm({ reportFailure("Scheduled task is incomplete", "Add a title, prompt, project, and model."); return; } + const parsedFilters = parseWebhookFilterDrafts(draft.filters); + if (draft.scheduleMode === "webhook" && "error" in parsedFilters) { + reportFailure("Invalid filter", parsedFilters.error); + return; + } const schedule = scheduleFromDraft(draft); if (schedule === null) { - reportFailure( - "Invalid age limit", - `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`, - ); + for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } if ( @@ -1175,7 +1305,8 @@ function ScheduledTaskEditorForm({ -

+ {/* Fieldsets default to min-width: min-content, which lets one wide control widen the form past the dialog. */} +
{!connected ? (

Reconnect this environment before saving.

) : null} @@ -1385,7 +1516,8 @@ function ScheduledTaskEditorForm({ minute.

) : null} -
+ {/* Wraps the toggle below the label when both don't fit, as on phones. */} +
+ setDraft((current) => ({ ...current, filters }))} + /> + + + setDraft((current) => ({ ...current, replayKey: event.target.value })) + } + /> + + + + setDraft((current) => ({ ...current, coalesceKey: event.target.value })) + } + /> + + + + setDraft((current) => ({ + ...current, + coalesceWindowSeconds: event.target.value, + })) + } + /> + +

+ Replay keys suppress repeat deliveries for 7 days. Coalescing runs the latest + payload once when the window from the first delivery closes. Empty keys run + normally. +

@@ -1536,9 +1726,6 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> - {runAtZone === undefined ? null : ( - {runAtZone} - )} on
({ ...current, catchUpMissed })) } /> + on
) : ( diff --git a/apps/web/src/components/settings/SecretInput.browser.test.tsx b/apps/web/src/components/settings/SecretInput.browser.test.tsx index 960214b5ca58..6a24510b1374 100644 --- a/apps/web/src/components/settings/SecretInput.browser.test.tsx +++ b/apps/web/src/components/settings/SecretInput.browser.test.tsx @@ -1,64 +1,40 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; +import { render, type Screen } from "../../../test/render"; import { SecretInput } from "./SecretInput"; -let root: Root; -let container: HTMLDivElement; +const input = (screen: Screen) => screen.container.querySelector("input")!; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); - -const input = () => container.querySelector("input")!; - -async function edit(value: string) { - await userEvent.clear(input()); - if (value) await userEvent.type(input(), value); +async function edit(screen: Screen, value: string) { + await userEvent.clear(input(screen)); + if (value) await userEvent.type(input(screen), value); await userEvent.click(document.body); } describe("SecretInput", () => { it("preserves a configured secret on empty edits and commits a replacement", async () => { const onCommit = vi.fn(); - await act(async () => root.render()); - expect(input().type).toBe("password"); + await using screen = await render(); + expect(input(screen).type).toBe("password"); - await userEvent.click(input()); + await userEvent.click(input(screen)); await userEvent.click(document.body); expect(onCommit).not.toHaveBeenCalled(); - await edit("replacement"); + await edit(screen, "replacement"); expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); - expect(input().value).toBe(""); - }); - - it("allows clearing an unsaved draft", async () => { - const onCommit = vi.fn(); - await act(async () => root.render()); - await edit(""); - expect(onCommit).toHaveBeenCalledExactlyOnceWith(""); + expect(input(screen).value).toBe(""); }); it("keeps an in-progress replacement when settings refresh", async () => { const onCommit = vi.fn(); - const render = () => root.render(); - await act(async () => render()); + const ui = () => ; + await using screen = await render(ui()); - await userEvent.type(input(), "replacement"); - await act(async () => render()); - expect(input().value).toBe("replacement"); + await userEvent.type(input(screen), "replacement"); + await screen.rerender(ui()); + expect(input(screen).value).toBe("replacement"); await userEvent.keyboard("{Enter}"); expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement"); diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts index 3b3d28e01a26..e02bb6b3d35e 100644 --- a/apps/web/src/components/settings/SettingInheritance.test.ts +++ b/apps/web/src/components/settings/SettingInheritance.test.ts @@ -8,53 +8,6 @@ const environmentId = EnvironmentId.make("laptop"); const projectId = ProjectId.make("project"); describe("settingInheritanceLayers", () => { - it("marks the built-in default effective when nothing is set", () => { - const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null); - const layers = settingInheritanceLayers( - { environmentId, label: "Laptop", projectId: null, ...resolved }, - DEFAULT_SERVER_SETTINGS, - "defaultAutoPull", - ); - expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ - ["Laptop", "Inherits", false], - ["Default", "Off", true], - ]); - }); - - it("walks project override, environment value, then built-in default", () => { - const settings = { - ...DEFAULT_SERVER_SETTINGS, - defaultAutoPull: true, - projectSettingsOverrides: { [projectId]: { defaultAutoPull: false } }, - }; - const resolved = resolveProjectSettings(settings, projectId); - const layers = settingInheritanceLayers( - { environmentId, label: "Laptop", projectId, ...resolved }, - settings, - "defaultAutoPull", - ); - expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ - ["Project", "Off", true], - ["Laptop", "On", false], - ["Default", "Off", false], - ]); - const inherited = settingInheritanceLayers( - { - environmentId, - label: "Laptop", - projectId, - ...resolveProjectSettings(settings, ProjectId.make("other")), - }, - settings, - "defaultAutoPull", - ); - expect(inherited.map((layer) => [layer.value, layer.effective])).toEqual([ - ["Inherits", false], - ["On", true], - ["Off", false], - ]); - }); - it("shows the checkout's t3.json as a layer for file-backed keys", () => { const file = { defaultThreadEnvMode: "worktree" as const }; const fromFile = settingInheritanceLayers( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 43594f29c19b..52d572b1479e 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,38 +1,12 @@ -import { - DEFAULT_SERVER_SETTINGS, - DEFAULT_UNIFIED_SETTINGS, - ProviderDriverKind, - ProviderInstanceId, - type ProviderInstanceConfig, -} from "@t3tools/contracts"; -import { getBackgroundActivityPresetSettings } from "@t3tools/shared/backgroundActivitySettings"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, - formatDiagnosticsDescription, getChangedBrowserSettingLabels, - getChangedTypographySettingLabels, hasChangedBackgroundActivitySettings, - isProjectGroupingEnabled, - projectGroupingModeFromToggle, - resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; -describe("typography settings restore", () => { - it("detects family and size changes by font row", () => { - expect(getChangedTypographySettingLabels(DEFAULT_UNIFIED_SETTINGS)).toEqual([]); - expect( - getChangedTypographySettingLabels({ - ...DEFAULT_UNIFIED_SETTINGS, - fontSizeInterface: 18, - fontFamilyCode: "Fira Code", - }), - ).toEqual(["Interface font", "Code font"]); - }); -}); - describe("background activity settings restore", () => { it("detects legacy interval values even when the structured setting is at its default", () => { expect( @@ -54,38 +28,6 @@ describe("background activity settings restore", () => { expect(hasChangedBackgroundActivitySettings(DEFAULT_UNIFIED_SETTINGS)).toBe(false); }); - it("detects a legacy profile override so restoring defaults clears it", () => { - expect( - hasChangedBackgroundActivitySettings({ - ...DEFAULT_UNIFIED_SETTINGS, - backgroundActivityProfile: "performance", - }), - ).toBe(true); - }); - - it("shows the effective legacy preset and marks custom legacy intervals as advanced", () => { - const performance = getBackgroundActivityPresetSettings("performance"); - expect( - resolveBackgroundActivityProfileOption({ - ...DEFAULT_UNIFIED_SETTINGS, - backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - backgroundActivityProfile: "performance", - automaticGitFetchInterval: performance.automaticGitFetchInterval, - providerHealthRefreshInterval: performance.providerHealthRefreshInterval, - }), - ).toBe("performance"); - - expect( - resolveBackgroundActivityProfileOption({ - ...DEFAULT_UNIFIED_SETTINGS, - backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, - backgroundActivityProfile: "performance", - automaticGitFetchInterval: Duration.seconds(45), - providerHealthRefreshInterval: Duration.minutes(7), - }), - ).toBe("advanced"); - }); - it("preserves advanced overrides when the shared policy changes", () => { const automaticGitFetchInterval = Duration.seconds(42); expect( @@ -136,98 +78,7 @@ describe("background activity settings restore", () => { }); }); -describe("project grouping toggle", () => { - it("enables repository grouping and disables into separate projects", () => { - expect(isProjectGroupingEnabled("repository")).toBe(true); - expect(isProjectGroupingEnabled("repository_path")).toBe(true); - expect(isProjectGroupingEnabled("separate")).toBe(false); - expect(projectGroupingModeFromToggle(true)).toBe("repository"); - expect(projectGroupingModeFromToggle(false)).toBe("separate"); - }); - - it("restores repository path grouping when the toggle is cycled", () => { - expect(projectGroupingModeFromToggle(false, "repository_path")).toBe("separate"); - expect(projectGroupingModeFromToggle(true, "repository_path")).toBe("repository_path"); - }); -}); - -describe("formatDiagnosticsDescription", () => { - it("collapses trace and metric URLs that share the same OTEL base path", () => { - expect( - formatDiagnosticsDescription({ - localTracingEnabled: true, - otlpTracesEnabled: true, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsEnabled: true, - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }), - ).toBe("Local trace file. Exporting OTEL to http://localhost:4318/v1/{traces,metrics}."); - }); - - it("keeps separate trace and metric URLs when their base paths differ", () => { - expect( - formatDiagnosticsDescription({ - localTracingEnabled: true, - otlpTracesEnabled: true, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsEnabled: true, - otlpMetricsUrl: "http://localhost:9000/v1/metrics", - }), - ).toBe( - "Local trace file. Exporting OTEL traces to http://localhost:4318/v1/traces and metrics to http://localhost:9000/v1/metrics.", - ); - }); - - it("omits OTEL text when no exporter is enabled", () => { - expect( - formatDiagnosticsDescription({ - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }), - ).toBe("Local trace file."); - }); -}); - -describe("buildProviderInstanceUpdatePatch", () => { - it("replaces one instance and keeps the others", () => { - const instanceId = ProviderInstanceId.make("codex"); - const otherId = ProviderInstanceId.make("codex_personal"); - const other = { - driver: ProviderDriverKind.make("codex"), - config: { homePath: "/Users/example/.codex-personal" }, - } satisfies ProviderInstanceConfig; - const nextInstance = { - driver: ProviderDriverKind.make("codex"), - enabled: true, - config: { binaryPath: "/opt/t3/codex" }, - } satisfies ProviderInstanceConfig; - - const patch = buildProviderInstanceUpdatePatch({ - settings: { providerInstances: { [otherId]: other } }, - instanceId, - instance: nextInstance, - }); - - expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance }); - expect(patch).not.toHaveProperty("textGenerationModelSelection"); - }); -}); - describe("getChangedBrowserSettingLabels", () => { - it("reports nothing for the defaults", () => { - expect(getChangedBrowserSettingLabels(DEFAULT_UNIFIED_SETTINGS)).toEqual([]); - }); - - it("treats a structurally equal viewport as unchanged", () => { - expect( - getChangedBrowserSettingLabels({ - ...DEFAULT_UNIFIED_SETTINGS, - browserDefaultViewport: { ...DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport }, - }), - ).toEqual([]); - }); - it("counts a retained custom search URL so restore clears it", () => { expect( getChangedBrowserSettingLabels({ @@ -236,31 +87,4 @@ describe("getChangedBrowserSettingLabels", () => { }), ).toEqual(["Search engine"]); }); - - it("labels each browser default that differs", () => { - expect( - getChangedBrowserSettingLabels({ - ...DEFAULT_UNIFIED_SETTINGS, - browserDefaultViewport: { _tag: "freeform", width: 900, height: 600 }, - browserDefaultZoomFactor: 1.5, - browserDefaultAppearance: "dark", - browserRecordingFrameRate: 60, - browserRecordingShowKeyPresses: true, - browserRecordingShowMousePresses: true, - browserLinkTarget: "app", - browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, - pullRequestLinksOpenInApp: false, - }), - ).toEqual([ - "Browser viewport", - "Browser zoom", - "Browser appearance", - "Recording frame rate", - "Recording key presses", - "Recording mouse presses", - "Open links in", - "Floating preview", - "Open pull requests in T3 Code", - ]); - }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d995f553d173..f7d60578989a 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -204,54 +204,6 @@ export function backgroundActivitySharedPolicySettings( }; } -function collapseOtelSignalsUrl(input: { - readonly tracesUrl: string; - readonly metricsUrl: string; -}): string | null { - const tracesSuffix = "/traces"; - const metricsSuffix = "/metrics"; - if (!input.tracesUrl.endsWith(tracesSuffix) || !input.metricsUrl.endsWith(metricsSuffix)) { - return null; - } - - const tracesBase = input.tracesUrl.slice(0, -tracesSuffix.length); - const metricsBase = input.metricsUrl.slice(0, -metricsSuffix.length); - if (tracesBase !== metricsBase) { - return null; - } - - return `${tracesBase}/{traces,metrics}`; -} - -export function formatDiagnosticsDescription(input: { - readonly localTracingEnabled: boolean; - readonly otlpTracesEnabled: boolean; - readonly otlpTracesUrl?: string | undefined; - readonly otlpMetricsEnabled: boolean; - readonly otlpMetricsUrl?: string | undefined; -}): string { - const mode = input.localTracingEnabled ? "Local trace file" : "Terminal logs only"; - const tracesUrl = input.otlpTracesEnabled ? input.otlpTracesUrl : undefined; - const metricsUrl = input.otlpMetricsEnabled ? input.otlpMetricsUrl : undefined; - - if (tracesUrl && metricsUrl) { - const collapsedUrl = collapseOtelSignalsUrl({ tracesUrl, metricsUrl }); - return collapsedUrl - ? `${mode}. Exporting OTEL to ${collapsedUrl}.` - : `${mode}. Exporting OTEL traces to ${tracesUrl} and metrics to ${metricsUrl}.`; - } - - if (tracesUrl) { - return `${mode}. Exporting OTEL traces to ${tracesUrl}.`; - } - - if (metricsUrl) { - return `${mode}. Exporting OTEL metrics to ${metricsUrl}.`; - } - - return `${mode}.`; -} - export function buildProviderInstanceUpdatePatch(input: { readonly settings: Pick; readonly instanceId: ProviderInstanceId; diff --git a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx b/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx deleted file mode 100644 index 2c813f07dac6..000000000000 --- a/apps/web/src/components/settings/SnapShotSettings.browser.test.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { - DEFAULT_CLIENT_SETTINGS, - type ClientSettingsPatch, - type DesktopSnapShotState, -} from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const bridge = vi.hoisted(() => ({ - getSnapShotState: vi.fn<() => Promise>(), - setSnapShotShortcutSuppressed: vi.fn(), - checkSnapShotShortcut: vi.fn(), - previewSnapShotConfig: vi.fn(), - applySnapShotConfig: vi.fn(), - setupSnapShot: vi.fn(), - requestSnapShotPermissions: vi.fn(), - onMenuAction: vi.fn(), - onSnapShotEvent: vi.fn(() => () => undefined), -})); -const toast = vi.hoisted(() => vi.fn()); -// Client settings behave like the real store: an update re-renders every reader. -const settingsStore = vi.hoisted(() => ({ - current: {} as typeof DEFAULT_CLIENT_SETTINGS, - listeners: new Set<() => void>(), - update: vi.fn<(patch: ClientSettingsPatch) => Promise>(), - set(next: typeof DEFAULT_CLIENT_SETTINGS) { - settingsStore.current = next; - for (const listener of settingsStore.listeners) listener(); - }, -})); -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - useLocation: ((options?: { select?: (location: unknown) => unknown }) => - options?.select?.({ hash: "", pathname: "/settings/snapshots", state: {} }) ?? "") as never, - useNavigate: (() => vi.fn()) as never, -})); -vi.mock(import("../../lib/desktopSnapShot"), async (original) => ({ - ...(await original()), - getDesktopSnapShotBridge: () => bridge as never, -})); -vi.mock(import("../ui/toast"), async (original) => { - const actual = await original(); - return { ...actual, toastManager: { ...actual.toastManager, add: toast } }; -}); -vi.mock(import("../../hooks/useSettings"), async (original) => { - const { useSyncExternalStore } = await import("react"); - const subscribe = (listener: () => void) => { - settingsStore.listeners.add(listener); - return () => settingsStore.listeners.delete(listener); - }; - return { - ...(await original()), - useClientSettings: (() => - useSyncExternalStore(subscribe, () => settingsStore.current)) as never, - updateClientSettings: settingsStore.update as never, - }; -}); - -import { readSnapShotSetupResume } from "../../lib/snapShotSetupResume"; -import { SnapShotSettings } from "./SnapShotSettings"; - -let state: DesktopSnapShotState; -let root: Root; -let container: HTMLDivElement; - -async function mount() { - root = createRoot(container); - await act(async () => root.render()); - await vi.waitFor(() => expect(bridge.getSnapShotState).toHaveBeenCalled()); - await act(async () => {}); -} -const dialog = () => document.querySelector('[role="dialog"]'); -const scope = () => dialog() ?? container; -function button(label: string, within: ParentNode = scope()) { - return ( - [...within.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label, - ) ?? null - ); -} -async function click(label: string) { - const target = button(label); - expect(target, `button ${label}`).not.toBeNull(); - await act(async () => target!.click()); -} -const recorder = () => scope().querySelector("[data-keybinding-capture]")!; -const toggle = (label: string) => - container.querySelector(`[role="switch"][aria-label="${label}"]`)!; -const stepTitle = () => dialog()?.querySelector("h3")?.textContent; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.clearAllMocks(); - localStorage.clear(); - Object.defineProperty(navigator, "platform", { configurable: true, get: () => "Linux" }); - settingsStore.current = { ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true }; - state = { - mode: "portal", - linuxBackend: "hyprland", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutActionRegistered: true, - shortcutMessage: null, - message: null, - hyprlandHelper: { status: "ready", message: "Ready" }, - }; - bridge.getSnapShotState.mockImplementation(async () => state); - bridge.setSnapShotShortcutSuppressed.mockResolvedValue(undefined); - bridge.checkSnapShotShortcut.mockResolvedValue({ available: true, message: null }); - bridge.requestSnapShotPermissions.mockResolvedValue(undefined); - bridge.setupSnapShot.mockReset().mockResolvedValue(undefined); - settingsStore.update.mockImplementation(async (patch) => { - state = { ...state, shortcut: patch.snapShotShortcut ?? state.shortcut }; - settingsStore.set({ ...settingsStore.current, ...patch }); - }); - container = document.createElement("div"); - document.body.append(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); - -it.each(["niri", "hyprland"] as const)( - "reopens %s setup at Shortcut without reading config or expanding Settings", - async (desktop) => { - state = { ...state, linuxBackend: desktop }; - await mount(); - expect(dialog()).toBeNull(); - expect(button("Review changes", container)).toBeNull(); - await click("Change shortcut"); - expect(stepTitle()).toBe("Choose your shortcut"); - expect(button("Review changes")).not.toBeNull(); - expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); - expect(bridge.applySnapShotConfig).not.toHaveBeenCalled(); - await click("Close"); - expect(dialog()).toBeNull(); - expect(settingsStore.update).not.toHaveBeenCalled(); - expect(settingsStore.current.snapShotEnabled).toBe(true); - }, -); - -it("returns to Access if the Hyprland helper needs attention before changing keys", async () => { - await mount(); - state = { ...state, hyprlandHelper: { status: "not-installed", message: "Install helper" } }; - await click("Change shortcut"); - expect(stepTitle()).toBe("Allow snapshots"); - expect(button("Install helper")).not.toBeNull(); - expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); -}); - -it.each(["direct", "gnome-extension", "kde"] as const)( - "keeps %s shortcut recording and saving inline", - async (backend) => { - state = { - ...state, - mode: backend === "direct" ? "direct" : "portal", - linuxBackend: backend === "direct" ? undefined : backend, - shortcutRegistered: true, - shortcutLabel: backend === "direct" ? undefined : "Press 2", - shortcutMessage: backend === "direct" ? null : "Desktop shortcut: Press 2", - }; - await mount(); - if (backend !== "direct") { - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Shift+2", - ); - } - await act(async () => userEvent.click(recorder())); - await act(async () => userEvent.keyboard("{Control>}{Alt>}y{/Alt}{/Control}")); - expect(bridge.checkSnapShotShortcut).toHaveBeenCalledOnce(); - expect(container.textContent).toContain("Ready to save."); - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Alt+Y", - ); - await click("Save"); - expect(settingsStore.update).toHaveBeenCalledWith({ - snapShotShortcut: expect.objectContaining({ key: "y", modKey: true, altKey: true }), - }); - expect(dialog()).toBeNull(); - expect(bridge.previewSnapShotConfig).not.toHaveBeenCalled(); - }, -); - -it("turns capture on directly on Windows without opening setup", async () => { - settingsStore.current = { ...settingsStore.current, snapShotEnabled: false }; - state = { ...state, mode: "direct", linuxBackend: undefined, windows: true }; - await mount(); - await act(async () => toggle("Enable snapshots").click()); - expect(settingsStore.update).toHaveBeenCalledWith({ snapShotEnabled: true }); - expect(toggle("Enable snapshots").getAttribute("aria-checked")).toBe("true"); - expect(dialog()).toBeNull(); - expect(button("Manage capture")).toBeNull(); -}); - -it("keeps the approved desktop shortcut when recording is cancelled in setup", async () => { - state = { - ...state, - linuxBackend: "gnome-extension", - gnomeExtension: { status: "enabled", message: "Ready" }, - shortcutRegistered: true, - shortcutLabel: "Press 8", - }; - await mount(); - await click("Manage capture"); - expect(dialog()).not.toBeNull(); - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Alt+8", - ); - await act(async () => userEvent.click(recorder())); - expect(recorder().textContent).toBe("Press shortcut…"); - await act(async () => userEvent.keyboard("{Escape}")); - expect(dialog()).not.toBeNull(); - expect(recorder().getAttribute("aria-label")).toBe( - "Record snapshot shortcut, currently Ctrl+Alt+8", - ); - expect(settingsStore.update).not.toHaveBeenCalled(); - expect(bridge.checkSnapShotShortcut).not.toHaveBeenCalled(); -}); - -function usePortalShortcut(shortcutCanRetry: boolean) { - const shortcut = { - key: "2", - modKey: false, - ctrlKey: true, - altKey: false, - shiftKey: true, - metaKey: false, - }; - settingsStore.current = { ...settingsStore.current, snapShotShortcut: shortcut }; - state = { - ...state, - linuxBackend: "gnome-extension", - gnomeExtension: { status: "enabled", message: "Ready" }, - shortcut, - linuxFeedbackAvailable: true, - shortcutRegistered: true, - shortcutCanRetry, - }; -} - -it("keeps older portal shortcuts editable without offering unsupported permissions", async () => { - usePortalShortcut(false); - await mount(); - expect(button("Shortcut permissions")).toBeNull(); - expect(bridge.setupSnapShot).not.toHaveBeenCalled(); - await act(async () => userEvent.click(recorder())); - expect(recorder().textContent).toBe("Press shortcut…"); -}); - -it("shows permission errors once in a toast and allows retrying", async () => { - usePortalShortcut(true); - bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected.")); - await mount(); - await click("Shortcut permissions"); - expect(toast).toHaveBeenCalledExactlyOnceWith({ - type: "error", - title: "Couldn't open shortcut permissions", - description: "The desktop service disconnected.", - }); - expect(container.querySelector('[role="alert"]')).toBeNull(); - await click("Shortcut permissions"); - expect(bridge.setupSnapShot).toHaveBeenCalledTimes(2); - expect(toast).toHaveBeenCalledTimes(1); -}); - -it("keeps a failed preference unchanged and reports the save error in a toast", async () => { - usePortalShortcut(true); - settingsStore.update.mockRejectedValueOnce(new Error("The settings file is read-only.")); - await mount(); - const flash = () => toggle("Flash captured window"); - expect(flash().getAttribute("aria-checked")).toBe("true"); - await act(async () => flash().click()); - expect(flash().getAttribute("aria-checked")).toBe("true"); - expect(toast).toHaveBeenCalledExactlyOnceWith({ - type: "error", - title: "Couldn't save capture settings", - description: "The settings file is read-only.", - }); - await act(async () => flash().click()); - expect(flash().getAttribute("aria-checked")).toBe("false"); - expect(toast).toHaveBeenCalledTimes(1); -}); - -it("keeps setup errors in the wizard and does not toast them after closing it", async () => { - usePortalShortcut(true); - state = { ...state, shortcutRegistered: false }; - await mount(); - await click("Manage capture"); - expect(stepTitle()).toBe("Choose your shortcut"); - bridge.setupSnapShot.mockRejectedValueOnce(new Error("The desktop service disconnected.")); - await click("Shortcut permissions"); - expect(dialog()!.querySelector('[role="alert"]')?.textContent).toContain( - "Couldn't finish this step", - ); - expect(dialog()!.querySelector("details")?.textContent).toContain( - "The desktop service disconnected.", - ); - expect(toast).not.toHaveBeenCalled(); - await click("Close"); - expect(dialog()).toBeNull(); - expect(toast).not.toHaveBeenCalled(); -}); - -function useMacPermissions(granted: boolean) { - settingsStore.current = { ...settingsStore.current, snapShotEnabled: false }; - state = { - mode: "direct", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: granted, - shortcutMessage: null, - message: null, - macPermissions: { screenRecording: granted, accessibility: granted }, - }; -} - -it.each([false, true])( - "resumes macOS permission setup after restart and clears it on close, completed=%s", - async (completed) => { - useMacPermissions(false); - await mount(); - await act(async () => toggle("Enable snapshots").click()); - expect(stepTitle()).toBe("Allow snapshots"); - bridge.setupSnapShot.mockImplementationOnce(async () => { - expect(readSnapShotSetupResume()).toEqual({ wasEnabled: false }); - }); - await click("Allow"); - expect(bridge.setupSnapShot).toHaveBeenCalledWith("allow-screen-recording"); - - // macOS quits the app from its permission prompt; the next launch resumes setup. - await act(async () => root.unmount()); - state = { ...state, shortcutRegistered: true }; - state = { ...state, macPermissions: { screenRecording: true, accessibility: true } }; - await mount(); - expect(stepTitle()).toBe("Allow snapshots"); - expect(button("Finish later")).not.toBeNull(); - if (completed) { - await click("Test capture and continue"); - expect(stepTitle()).toBe("Choose your shortcut"); - await click("Done"); - } else { - await click("Finish later"); - } - expect(dialog()).toBeNull(); - expect(readSnapShotSetupResume()).toBeNull(); - }, -); - -it("requires a successful macOS test capture before enabling and allows retry", async () => { - useMacPermissions(true); - await mount(); - await act(async () => toggle("Enable snapshots").click()); - bridge.setupSnapShot.mockRejectedValueOnce(new Error("Capture was denied")); - await click("Test capture and continue"); - expect(dialog()!.querySelector('[role="alert"]')).not.toBeNull(); - expect(dialog()!.querySelector("details")?.textContent).toContain("Capture was denied"); - expect(stepTitle()).toBe("Allow snapshots"); - expect(settingsStore.current.snapShotEnabled).toBe(false); - await click("Test capture and continue"); - expect(stepTitle()).toBe("Choose your shortcut"); - expect(bridge.setupSnapShot.mock.calls).toEqual([["test-mac-capture"], ["test-mac-capture"]]); - expect(settingsStore.current.snapShotEnabled).toBe(true); -}); diff --git a/apps/web/src/components/settings/SnapShotSettings.logic.test.ts b/apps/web/src/components/settings/SnapShotSettings.logic.test.ts index e69ac73a3270..85d319d6b5a8 100644 --- a/apps/web/src/components/settings/SnapShotSettings.logic.test.ts +++ b/apps/web/src/components/settings/SnapShotSettings.logic.test.ts @@ -5,43 +5,11 @@ import { createRecordingRequestTracker, snapShotStatus, snapShotShortcutStatus, - snapShotUnavailableMessage, - snapShotSoundPatch, - snapShotFeedbackUnavailableMessage, snapShotSetupSummary, snapShotSetupButtonLabel, snapShotSetupComplete, - snapShotDescription, - snapShotAccessibilityUnavailableMessage, } from "./SnapShotSettings.logic"; -it.each([ - ["off", { snapShotPlaySound: false }], - ["soft-pop", { snapShotPlaySound: true, snapShotSound: "soft-pop" }], - ["camera-shutter", { snapShotPlaySound: true, snapShotSound: "camera-shutter" }], -] as const)("maps %s to compatible capture settings", (sound, patch) => { - expect(snapShotSoundPatch(sound)).toEqual(patch); -}); - -it("offers effects only with a capable GNOME extension, explaining how to upgrade v1", () => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxBackend: "gnome-extension", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: true, - shortcutMessage: null, - message: null, - }; - expect(snapShotFeedbackUnavailableMessage(state)).toContain("Update"); - expect( - snapShotFeedbackUnavailableMessage({ ...state, linuxFeedbackAvailable: true }), - ).toBeUndefined(); - expect(snapShotFeedbackUnavailableMessage({ ...state, linuxBackend: "picker" })).toContain( - "aren't available", - ); - expect(snapShotFeedbackUnavailableMessage({ ...state, mode: "direct" })).toBeUndefined(); -}); - it("ignores a stale request after a newer request starts", () => { const requests = createRecordingRequestTracker(); const firstRequest = requests.tryBegin(); @@ -56,67 +24,7 @@ it("ignores a stale request after a newer request starts", () => { expect(requests.tryBegin()).toBeNull(); }); -it("reports unavailable capture support without browser globals", () => { - expect(snapShotUnavailableMessage(false)).toBe("Only available in the desktop app."); -}); - -it("describes Niri setup without claiming a global shortcut is registered", () => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxBackend: "niri", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutMessage: "Managed by Niri", - message: null, - }; - expect(snapShotStatus(state, true)).toBe("Finish shortcut setup"); - expect(snapShotStatus(state, true)).not.toContain("could not be registered"); - expect(snapShotFeedbackUnavailableMessage(state)).toContain("aren't available on Niri"); -}); - -it("distinguishes Hyprland helper setup, action registration, and verified shortcut delivery", () => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxBackend: "hyprland", - linuxDesktop: "hyprland", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutMessage: "Connecting to Hyprland shortcuts…", - message: null, - hyprlandHelper: { status: "not-installed", message: "Install helper" }, - }; - expect(snapShotSetupButtonLabel(state)).toBe("Set up Hyprland capture"); - expect(snapShotStatus(state, false)).toBe("Turn this on to set up snapshots."); - expect(snapShotStatus(state, true)).toContain("Install the capture helper"); - expect(snapShotFeedbackUnavailableMessage(state)).toContain("Install or update"); - const ready = { - ...state, - hyprlandHelper: { status: "ready" as const, message: "Ready" }, - linuxFeedbackAvailable: true, - }; - expect(snapShotSetupButtonLabel(ready)).toBe("Manage capture"); - expect(snapShotShortcutStatus({ ...ready, shortcutPending: true })).not.toContain("permission"); - expect(snapShotStatus({ ...ready, shortcutActionRegistered: true }, true)).toBe( - "Use your shortcut from another app", - ); - expect(snapShotStatus({ ...ready, shortcutVerified: true }, true)).toBe("Ready to capture"); - expect(snapShotFeedbackUnavailableMessage(ready)).toBeUndefined(); - expect(snapShotAccessibilityUnavailableMessage(ready)).toBeUndefined(); -}); - -it("keeps unavailable capture distinct from the opt-in setup prompt", () => { - const state: DesktopSnapShotState = { - mode: "unavailable", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutMessage: null, - message: "Wayland is required.", - }; - - expect(snapShotStatus(state, false)).toBe("Wayland is required."); -}); - -it.each(["gnome-extension", "niri", "screenshot-portal", "picker"] as const)( +it.each(["gnome-extension", "picker"] as const)( "waits for opt-in before presenting %s setup requirements", (linuxBackend) => { const state: DesktopSnapShotState = { @@ -194,78 +102,6 @@ it("distinguishes saved shortcuts from observed delivery without making users re ).toBe("Ready to capture"); }); -it.each([ - ["gnome", "GNOME"], - ["kde", "KDE Plasma"], - ["niri", "Niri"], -] as const)( - "names %s when setup cannot yet determine the capture backend", - (linuxDesktop, name) => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxDesktop, - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutMessage: null, - message: "Capability check failed", - }; - expect(snapShotSetupButtonLabel(state)).toBe(`Set up ${name} capture`); - }, -); - -it("keeps picker limitations visible after shortcut verification without recommending a GNOME extension", () => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxBackend: "picker", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: true, - shortcutMessage: null, - message: null, - shortcutVerified: true, - }; - expect(snapShotStatus(state, true)).toContain("Manual capture only"); - expect(snapShotDescription(state)).toContain("Automatic capture isn't available"); - expect(snapShotFeedbackUnavailableMessage(state)).not.toContain("GNOME"); - expect(snapShotAccessibilityUnavailableMessage(state)).toContain("only provides a screenshot"); - expect( - snapShotAccessibilityUnavailableMessage({ ...state, linuxBackend: "screenshot-portal" }), - ).toContain("only provides a screenshot"); - expect( - snapShotAccessibilityUnavailableMessage({ ...state, linuxBackend: "kde" }), - ).toBeUndefined(); - expect(snapShotFeedbackUnavailableMessage({ ...state, linuxBackend: "kde" })).toContain( - "capture helper", - ); - expect( - snapShotFeedbackUnavailableMessage({ - ...state, - linuxBackend: "kde", - linuxFeedbackAvailable: true, - kdeHelper: { status: "ready", message: "Ready", feedbackAvailable: true }, - }), - ).toBeUndefined(); - expect( - snapShotStatus( - { ...state, linuxBackend: "kde", kdeHelper: { status: "not-installed", message: "Install" } }, - true, - ), - ).toContain("Install the capture helper"); -}); - -it("does not ask Niri users to repeat setup when its capture endpoint is available", () => { - const state: DesktopSnapShotState = { - mode: "portal", - linuxBackend: "niri", - shortcut: DEFAULT_CLIENT_SETTINGS.snapShotShortcut, - shortcutRegistered: false, - shortcutBinding: "Ctrl+Shift+2 { spawn ...; }", - shortcutMessage: null, - message: null, - }; - expect(snapShotStatus(state, true)).toBe("Use your shortcut from another app"); - expect(snapShotSetupButtonLabel(state)).toBe("Manage capture"); -}); - it("reports pending, denied, and assigned shortcuts without inferring consent from saved keys", () => { const state: DesktopSnapShotState = { mode: "portal", diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts b/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts index 93dc091fbad0..cd1ad79dfd29 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts +++ b/apps/web/src/components/settings/SnapShotSetupDialog.logic.test.ts @@ -44,14 +44,7 @@ it("derives Hyprland setup from its own helper and registered action, not old GN expect(captureSetupAccessReady({ ...installed, message: "Check access" })).toBe(false); }); -it.each([ - "not-installed", - "disabled", - "restart-required", - "update-required", - "extensions-disabled", - "error", -] as const)( +it.each(["disabled", "error"] as const)( "keeps %s GNOME setup on the access step, even with a previously verified shortcut", (status) => { const state = { @@ -82,16 +75,6 @@ it("resumes after login from real extension state without an onboarding-complete expect(captureSetupInitialStep(gnome)).toBe("shortcut"); }); -it("finishes setup with a saved shortcut without requiring a separate delivery test", () => { - expect(captureSetupInitialStep({ ...gnome, shortcutVerified: false })).toBe("shortcut"); - expect(captureSetupShortcutReady(gnome, false)).toBe(true); -}); - -it("still allows revisiting capture access and editing a saved shortcut", () => { - expect(captureSetupInitialStep(gnome, "access")).toBe("access"); - expect(captureSetupInitialStep(gnome, "shortcut")).toBe("shortcut"); -}); - it("does not skip native permission setup when capture has not been enabled", () => { expect( captureSetupInitialStep({ @@ -104,12 +87,6 @@ it("does not skip native permission setup when capture has not been enabled", () ).toBe("access"); }); -it("keeps the picker explanation when automatic capture is unavailable", () => { - expect( - captureSetupInitialStep({ ...gnome, linuxBackend: "picker", gnomeExtension: undefined }), - ).toBe("access"); -}); - it("offers manual capture on an unsupported GNOME version instead of trapping setup", () => { const state = { ...gnome, @@ -122,7 +99,7 @@ it("offers manual capture on an unsupported GNOME version instead of trapping se expect(captureSetupCheckMessage(state)).toContain("choose a window each time"); }); -it.each(["not-installed", "update-required", "error"] as const)( +it.each(["not-installed", "error"] as const)( "requires KDE helper access for %s even with a saved shortcut", (status) => { const state = { @@ -183,39 +160,6 @@ it("uses the current desktop's access requirements while preserving setup for th expect(captureSetupShortcutReady(gnome, false)).toBe(true); }); -it("does not infer a Linux desktop for native capture or an unidentified portal", () => { - expect(captureSetupDesktopName({ ...gnome, mode: "direct" })).toBeUndefined(); - expect( - captureSetupDesktopName({ - ...gnome, - linuxBackend: "screenshot-portal", - gnomeExtension: undefined, - }), - ).toBeUndefined(); -}); - -it("acknowledges an unchanged recheck while GNOME still needs a sign-out", () => { - const state = { - ...gnome, - gnomeExtension: { status: "restart-required" as const, message: "Sign out" }, - }; - expect(captureSetupCheckMessage(state)).toBe("Still waiting for you to sign out and back in."); - expect(captureSetupAccessReady(state)).toBe(false); -}); - -it("only confirms capture access when the rechecked extension is running and reachable", () => { - expect(captureSetupCheckMessage(gnome)).toBe("Ready. Continue to choose your shortcut."); - expect(captureSetupCheckMessage({ ...gnome, linuxBackend: "picker" })).toBe( - "Not ready yet. Finish the step above.", - ); - expect( - captureSetupCheckMessage({ - ...gnome, - gnomeExtension: { status: "disabled", message: "Enable it" }, - }), - ).toBe("Not ready yet. Finish the step above."); -}); - it("does not report a successful check when capture support could not be read", () => { expect(captureSetupCheckMessage({ ...gnome, message: "Desktop disconnected" })).toContain( "Still unable to check access", @@ -275,15 +219,6 @@ it("allows leaving setup while the desktop prompt is pending but never after an expect(captureSetupShortcutReady({ ...pending, shortcutPending: false }, false)).toBe(false); }); -it.each([false, true])( - "does not require a previously observed shortcut activation (%s)", - (shortcutVerified) => { - const state = { ...gnome, shortcutVerified }; - expect(captureSetupShortcutReady(state, false)).toBe(true); - expect(captureSetupInitialStep(state)).toBe("shortcut"); - }, -); - it("blocks finishing if desktop access is lost during the wizard", () => { expect( captureSetupShortcutReady( @@ -300,9 +235,7 @@ it("blocks finishing if desktop access is lost during the wizard", () => { it.each([ [false, false, true], - [false, true, false], [true, false, false], - [true, true, false], ] as const)( "closing setup (previously enabled=%s, completed=%s) disables only an unfinished first opt-in", (wasEnabled, completed, disable) => { diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx index 326a1d4c2f8e..99bcd86c1947 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.browser.test.tsx @@ -1,7 +1,7 @@ +import { render } from "../../../test/render"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { act, StrictMode, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { StrictMode, type ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "../../index.css"; @@ -61,29 +61,16 @@ vi.mock(import("./settingsLayout"), async (original) => ({ import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; -let root: Root; -let container: HTMLDivElement; - const resetButton = (label: string) => page.getByRole("button", { name: `Reset ${label} to default` }); -const writeForAll = () => page.getByRole("button", { name: "Write custom instructions for all" }); -const applyToAll = () => page.getByRole("button", { name: "Apply instructions to all" }); -const bulkEditor = () => - page.getByRole("textbox", { - name: "Custom source control instructions for all selected environments", - }); -async function render() { - await act(async () => - root.render( - - - , - ), - ); -} +const content = () => ( + + + +); -beforeEach(async () => { +async function setup() { state.styles = [ { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle, @@ -98,23 +85,17 @@ beforeEach(async () => { followChangeRequestTemplates: false, }, ]; + const screen = await render(content()); state.updateSettings.mockReset().mockImplementation((patch) => { state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle })); - void render(); + void screen.rerender(content()); }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await render(); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); + return screen; +} describe("mixed source control instructions", () => { it("resets mixed template preferences without replacing each environment's instructions", async () => { + await using _screen = await setup(); const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions, @@ -131,73 +112,4 @@ describe("mixed source control instructions", () => { state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), ).toEqual(initialInstructions); }); - - it("resets every environment even when the representative already has default instructions", async () => { - state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle }; - await render(); - - await userEvent.click(resetButton("source control writing style")); - - expect(state.updateSettings).toHaveBeenCalledTimes(1); - expect( - state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })), - ).toEqual( - [0, 1].map(() => ({ - mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode, - customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions, - })), - ); - expect(state.styles[1]!.followChangeRequestTemplates).toBe(false); - }); - - it("does not write an untouched bulk draft", async () => { - const initialStyles = state.styles; - await userEvent.click(writeForAll()); - await expect.element(bulkEditor()).toHaveValue(""); - await expect.element(applyToAll()).toBeDisabled(); - - expect(state.updateSettings).not.toHaveBeenCalled(); - expect(state.styles).toEqual(initialStyles); - }); - - it("applies edited instructions to every selected environment", async () => { - await userEvent.click(writeForAll()); - await userEvent.fill(bulkEditor(), " Keep titles concise. "); - await expect.element(applyToAll()).toBeEnabled(); - await userEvent.click(applyToAll()); - - expect(state.updateSettings).toHaveBeenCalledTimes(1); - expect(state.styles.map((style) => style.customInstructions)).toEqual([ - "Keep titles concise.", - "Keep titles concise.", - ]); - expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]); - }); - - it("allows an intentional clear after editing", async () => { - await userEvent.click(writeForAll()); - await userEvent.fill(bulkEditor(), "Temporary instructions"); - await userEvent.clear(bulkEditor()); - await expect.element(applyToAll()).toBeEnabled(); - await userEvent.click(applyToAll()); - - expect(state.updateSettings).toHaveBeenCalledTimes(1); - expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]); - }); - - it("shows the plain editor once instructions agree, even while templates differ", async () => { - await userEvent.click(writeForAll()); - await userEvent.fill(bulkEditor(), "Shared instructions"); - await userEvent.click(applyToAll()); - - expect(state.updateSettings).toHaveBeenCalledTimes(1); - expect(state.styles.map((style) => style.customInstructions)).toEqual([ - "Shared instructions", - "Shared instructions", - ]); - await expect.element(writeForAll()).not.toBeInTheDocument(); - await expect - .element(page.getByRole("textbox", { name: "Custom source control writing instructions" })) - .toHaveValue("Shared instructions"); - }); }); diff --git a/apps/web/src/components/settings/StorageSettings.browser.test.tsx b/apps/web/src/components/settings/StorageSettings.browser.test.tsx index 47f9208d13d0..18b30ccffc44 100644 --- a/apps/web/src/components/settings/StorageSettings.browser.test.tsx +++ b/apps/web/src/components/settings/StorageSettings.browser.test.tsx @@ -1,14 +1,8 @@ +import { act, render } from "../../../test/render"; import { RegistryProvider } from "@effect/atom-react"; -import { - DEFAULT_SERVER_SETTINGS, - ProjectId, - ThreadId, - type StorageCleanupReport, -} from "@t3tools/contracts"; -import { act, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { DEFAULT_SERVER_SETTINGS, type StorageCleanupReport } from "@t3tools/contracts"; +import { type ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ settings: {} as typeof DEFAULT_SERVER_SETTINGS, @@ -92,10 +86,7 @@ vi.mock(import("../ui/toast"), async (original) => { import { StorageSettingsPanel } from "./StorageSettings"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { +function setup() { state.settings = { ...DEFAULT_SERVER_SETTINGS, storageCleanup: { @@ -113,149 +104,24 @@ beforeEach(() => { }; state.report = null; state.run.mockReset(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - -async function render() { - await act(async () => - root.render( - - - , - ), - ); - return Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Delete now", - )!; } -it.each(["machine-wide immediate", "project custom"])( - "runs cleanup when only %s settled retention is enabled", - async (scope) => { - if (scope === "machine-wide immediate") { - state.settings = { - ...state.settings, - storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 }, - }; - } else { - state.settings = { - ...state.settings, - projectSettingsOverrides: { - [ProjectId.make("project")]: { - worktreeCleanup: { - mode: "custom", - rules: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 7 }, - }, - }, - }, - }; - } - const report: StorageCleanupReport = { - trigger: "manual", - startedAt: "2026-10-14T12:00:00.000Z", - finishedAt: "2026-10-14T12:00:01.000Z", - entries: [], - counts: { removed: 0, kept: 0, failed: 0 }, - omittedCount: 0, - bytesFreed: 0, - }; - let complete!: (result: { _tag: "Success"; value: StorageCleanupReport }) => void; - state.run.mockImplementation(() => new Promise((resolve) => (complete = resolve))); - const button = await render(); - - expect(button.disabled).toBe(false); - await act(async () => button.click()); - expect(state.run).toHaveBeenCalledExactlyOnceWith({ environmentId: "env", input: {} }); - expect(button.disabled).toBe(true); - expect(button.textContent).toBe("Deleting…"); - - await act(async () => complete({ _tag: "Success", value: report })); - expect(button.disabled).toBe(false); - expect(button.textContent).toBe("Delete now"); - }, +const panel = () => ( + + + ); +const deleteButton = (container: HTMLElement) => + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Delete now", + )!; it("keeps cleanup disabled when settled retention and all other rules are off", async () => { - const button = await render(); + setup(); + await using screen = await render(panel()); + const button = deleteButton(screen.container); expect(button.disabled).toBe(true); await act(async () => button.click()); expect(state.run).not.toHaveBeenCalled(); }); - -it("shows orphan sizes and identifies failed worktrees with one connected machine", async () => { - state.settings = { - ...state.settings, - storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 }, - }; - state.report = { - trigger: "manual", - startedAt: "2026-10-14T12:00:00.000Z", - finishedAt: "2026-10-14T12:00:01.000Z", - entries: [ - { - kind: "worktree", - outcome: "kept", - path: "/worktrees/repo/orphan", - projectPath: null, - threadId: null, - threadTitle: null, - reason: "No thread uses this folder, and it isn't a git worktree", - bytes: 6600000, - files: null, - }, - { - kind: "worktree", - outcome: "failed", - path: "/worktrees/repo/thread-checkout", - projectPath: "/projects/repo", - threadId: ThreadId.make("thread"), - threadTitle: "Thread title", - reason: "Git index is locked. Status will resume when the index lock is removed.", - bytes: null, - files: null, - }, - ], - counts: { removed: 0, kept: 1, failed: 1 }, - omittedCount: 0, - bytesFreed: 0, - }; - await render(); - const lastRun = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.includes("Last run"), - )!; - await act(async () => lastRun.click()); - const titles = Array.from(container.querySelectorAll("h3"), (heading) => heading.textContent); - expect(titles).toContain("/worktrees/repo/orphan"); - expect(titles).toContain("thread-checkout"); - const failedRow = page.elementLocator( - page.getByRole("heading", { name: "thread-checkout", exact: true }).element().parentElement!, - ); - await expect.element(failedRow.getByText("Build workstation", { exact: true })).toBeVisible(); - await expect.element(failedRow.getByText("/projects/repo", { exact: true })).toBeVisible(); - await expect - .element(failedRow.getByText("/worktrees/repo/thread-checkout", { exact: true })) - .toBeVisible(); - await expect.element(failedRow.getByText("Thread title", { exact: true })).toBeVisible(); - await expect - .element( - failedRow.getByText( - "Git index is locked. Status will resume when the index lock is removed.", - { exact: true }, - ), - ) - .toBeVisible(); - expect(titles).not.toContain("/worktrees/repo/thread-checkout"); - expect(container.textContent).toContain( - "No thread uses this folder, and it isn't a git worktree", - ); - expect(container.textContent).toContain("6.29 MB"); - expect(container.textContent).toContain("0 B recovered."); -}); diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index e360c8e3a5ec..be78a1970cb4 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -746,19 +746,14 @@ export function StorageSettingsPanel() { return ( - {!isProjectScope && - connectedEnvironments - .filter( + {!isProjectScope && ( + environment.serverConfig?.environment.capabilities.databaseStorage === true, - ) - .map((environment) => ( - - ))} + )} + /> + )} {!isProjectScope && } {isProjectScope && ( diff --git a/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx b/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx index 278442ed468f..212787aa1e0f 100644 --- a/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx +++ b/apps/web/src/components/settings/TelemetryExportSettings.browser.test.tsx @@ -1,7 +1,6 @@ +import { render } from "../../../test/render"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; const { state, updateSettings } = vi.hoisted(() => ({ @@ -46,40 +45,29 @@ vi.mock(import("./useScopedSettings"), async (original) => ({ import { TelemetryExportSettings } from "./TelemetryExportSettings"; -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); +function setup() { updateSettings.mockClear(); - vi.unstubAllGlobals(); -}); - -const field = (id: string) => container.querySelector(`#${id}`)!; -async function replace(id: string, value: string) { - await userEvent.clear(field(id)); - await userEvent.type(field(id), value); } describe("TelemetryExportSettings", () => { it("does not resend a field the user restored after another client changed it", async () => { + setup(); state.observability = { ...DEFAULT_SERVER_SETTINGS.observability, otlpTracesUrl: "http://old/v1/traces", }; - await act(async () => root.render()); + await using screen = await render(); + const { container } = screen; + const field = (id: string) => container.querySelector(`#${id}`)!; + async function replace(id: string, value: string) { + await userEvent.clear(field(id)); + await userEvent.type(field(id), value); + } await replace("otlpTracesUrl", "http://draft/v1/traces"); await replace("otlpTracesUrl", "http://old/v1/traces"); state.observability = { ...state.observability, otlpTracesUrl: "http://other/v1/traces" }; - await act(async () => root.render()); + await screen.rerender(); await replace("otlpMetricsUrl", "http://new/v1/metrics"); const save = [...container.querySelectorAll("button")].find( (button) => button.textContent === "Save", diff --git a/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx b/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx deleted file mode 100644 index f625a4471e93..000000000000 --- a/apps/web/src/components/settings/ThemeEditorHost.browser.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - installCustomTheme, - invalidateCustomThemes, - parseThemeFile, - removeCustomTheme, - THEME_FILE_VERSION, - themeColorToHex, - updateCustomTheme, - type ThemeDefinition, -} from "../../themePalette"; - -vi.mock(import("../../hooks/useTheme"), async (original) => ({ - ...(await original()), - useTheme: () => - ({ theme: "system", themeHalves: null, setTheme: vi.fn(), refreshTheme: vi.fn() }) as never, -})); -// The real panel is a large lazy editor; this stand-in shows which theme the host hands it. -vi.mock(import("./ThemeEditorPanel"), async (original) => ({ - ...(await original()), - ThemeEditorPanel: (({ - editingTheme, - seedTheme, - }: { - editingTheme: ThemeDefinition | null; - seedTheme: ThemeDefinition | null; - }) => ( -
-
editingTheme
-
{editingTheme ? themeColorToHex(editingTheme.colors.accent ?? "") : "none"}
-
seedTheme
-
{seedTheme ? themeColorToHex(seedTheme.colors.accent ?? "") : "none"}
-
- )) as never, -})); - -import { ThemeEditorHost } from "./ThemeEditorHost"; -import { useThemeEditorStore } from "./themeEditorStore"; - -type Field = "editingTheme" | "seedTheme"; -let root: Root; -let container: HTMLDivElement; - -function install(id: string) { - return installCustomTheme( - parseThemeFile({ - version: THEME_FILE_VERSION, - id, - name: id, - appearance: "dark", - colors: { accent: "#1f6e4a" }, - }), - ); -} -function open(field: Field, themeId: string) { - return act(async () => - useThemeEditorStore.getState().openThemeEditor({ - editingThemeId: field === "editingTheme" ? themeId : null, - seedThemeId: field === "seedTheme" ? themeId : null, - seedName: null, - initialAppearance: "dark", - }), - ); -} -async function shown(field: Field) { - await vi.waitFor(() => expect(container.querySelector("dl")).not.toBeNull(), { - timeout: 5_000, - }); - const terms = [...container.querySelectorAll("dt")]; - return terms.find((term) => term.textContent === field)?.nextElementSibling?.textContent; -} - -// The host lazy-loads the panel; load it once so the first test does not race the import. -beforeAll(() => import("./ThemeEditorPanel")); - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - localStorage.clear(); - invalidateCustomThemes(); - useThemeEditorStore.setState({ session: null }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - await act(async () => root.render()); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - localStorage.clear(); - invalidateCustomThemes(); - vi.unstubAllGlobals(); -}); - -describe("ThemeEditorHost", () => { - it.each(["editingTheme", "seedTheme"] as const)( - "reopens the same %s with its saved colors", - async (field) => { - const theme = install("saved-colors"); - await open(field, theme.id); - expect(await shown(field)).toBe("#1f6e4a"); - - await act(async () => useThemeEditorStore.getState().closeThemeEditor()); - expect(container.querySelector("dl")).toBeNull(); - updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }); - await open(field, theme.id); - - expect(await shown(field)).toBe("#7241b8"); - }, - ); - - it.each(["editingTheme", "seedTheme"] as const)( - "refreshes an open %s when the library changes", - async (field) => { - const theme = install("updated-theme"); - await open(field, theme.id); - expect(await shown(field)).toBe("#1f6e4a"); - - await act(async () => - updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }), - ); - - expect(await shown(field)).toBe("#7241b8"); - }, - ); - - it("does not keep editing a theme removed from the library", async () => { - const theme = install("removed-theme"); - await open("editingTheme", theme.id); - expect(await shown("editingTheme")).toBe("#1f6e4a"); - - await act(async () => removeCustomTheme(theme.id)); - - expect(await shown("editingTheme")).toBe("none"); - }); -}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts deleted file mode 100644 index 6cd51e9b77ae..000000000000 --- a/apps/web/src/components/settings/ThemeImportDialog.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; - -describe("theme import size guard", () => { - it("accepts anything a theme file could plausibly be", () => { - for (const bytes of [0, 4_096, MAX_THEME_FILE_BYTES]) { - expect(describeOversizedThemeFile(bytes)).toBeNull(); - } - }); - - it("rejects a file too large to be a theme and names its size", () => { - const message = describeOversizedThemeFile(100 * 1024 * 1024); - expect(message).toContain("100.0 MB"); - expect(message).toContain("256 KB"); - }); - - it("reports sizes just past the limit in KB", () => { - expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); - }); -}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 3f9c62db1f05..3d6eb07a896d 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -37,7 +37,7 @@ import { ThemeSearchSection } from "./ThemeSearchSection"; * would otherwise be pulled into memory, highlighted, and rendered, which * locks the UI for as long as that takes. */ -export const MAX_THEME_FILE_BYTES = 256 * 1024; +const MAX_THEME_FILE_BYTES = 256 * 1024; /** Highlighting rebuilds the whole markup on every keystroke, so oversized * pastes fall back to plain text instead of freezing the editor. */ @@ -49,7 +49,7 @@ function formatByteSize(bytes: number): string { return `${bytes} bytes`; } -export function describeOversizedThemeFile(bytes: number): string | null { +function describeOversizedThemeFile(bytes: number): string | null { if (bytes <= MAX_THEME_FILE_BYTES) return null; return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; } diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts index ac82700f390a..d4d719c3b789 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -2,15 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import type { BrowserImportSource } from "@t3tools/contracts"; import { - canCloseWizard, initialWizardStep, initialTargetSelection, - isRetryableReason, - formatSkippedDomains, - fullDiskAccessRecheckStep, - outcomeToStep, refreshedSourceProfileDirectory, - refreshedSourceStep, resolveWizardTarget, } from "./browserImportWizard.logic"; @@ -44,37 +38,6 @@ const source = (over: Partial = {}): BrowserImportSource => }); describe("initialWizardStep", () => { - it("opens on the quit screen when the browser is running", () => { - expect(initialWizardStep(source({ unavailable: "browserRunning" }))).toEqual({ step: "quit" }); - }); - - it("opens on configure when the source is ready", () => { - expect(initialWizardStep(source())).toEqual({ step: "configure" }); - }); - - it("asks for Full Disk Access before choosing an import target", () => { - expect(initialWizardStep(source({ profiles: [], unavailable: "needsFullDiskAccess" }))).toEqual( - { - step: "fullDiskAccess", - resume: "configure", - }, - ); - }); - - it("blocks on a reason nothing local can fix", () => { - expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({ - step: "blocked", - reason: "unsupportedPlatform", - }); - }); - - it("blocks a ready source that reports no importable profiles", () => { - expect(initialWizardStep(source({ profiles: [] }))).toEqual({ - step: "blocked", - reason: "unknownSourceProfile", - }); - }); - it("handles source availability before checking its profiles", () => { expect( initialWizardStep(source({ profiles: [], unavailable: "needsKeychainApproval" })), @@ -82,90 +45,6 @@ describe("initialWizardStep", () => { }); }); -describe("canCloseWizard", () => { - it("keeps the wizard open while an import writes its target profile", () => { - expect(canCloseWizard({ step: "importing" })).toBe(false); - expect(canCloseWizard({ step: "configure" })).toBe(true); - expect(canCloseWizard({ step: "blocked", reason: "readFailed" })).toBe(true); - }); -}); - -describe("outcomeToStep", () => { - it("lands on done after a successful import", () => { - expect( - outcomeToStep({ - kind: "imported", - imported: 12, - skipped: 3, - skippedDomains: ["example.com"], - targetName: "Work", - }), - ).toEqual({ - step: "done", - imported: 12, - skipped: 3, - skippedDomains: ["example.com"], - targetName: "Work", - }); - }); - - it("routes a reopened browser back to the quit screen", () => { - expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" }); - }); - - it("routes a Full Disk Access refusal to its own screen", () => { - expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({ - step: "fullDiskAccess", - resume: "import", - checked: true, - }); - }); - - it("surfaces every other failure on the blocked screen", () => { - expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ - step: "blocked", - reason: "readFailed", - }); - }); -}); - -describe("refreshedSourceStep", () => { - it("moves to configure once a quit browser frees its cookies", () => { - expect(refreshedSourceStep(source())).toEqual({ step: "configure" }); - }); - - it("stays on quit while the browser is still running", () => { - expect(refreshedSourceStep(source({ unavailable: "browserRunning" }))).toEqual({ - step: "quit", - }); - }); - - it("blocks when the source vanished from the list", () => { - expect(refreshedSourceStep(undefined)).toEqual({ step: "blocked", reason: "unknownSource" }); - }); - - it("blocks when the refreshed source has no profiles", () => { - expect(refreshedSourceStep(source({ profiles: [] }))).toEqual({ - step: "blocked", - reason: "unknownSourceProfile", - }); - }); -}); - -describe("fullDiskAccessRecheckStep", () => { - it("marks a still-denied access check for visible feedback", () => { - expect(fullDiskAccessRecheckStep(source({ unavailable: "needsFullDiskAccess" }))).toEqual({ - step: "fullDiskAccess", - resume: "configure", - checked: true, - }); - }); - - it("moves on once access reveals the source profiles", () => { - expect(fullDiskAccessRecheckStep(source())).toEqual({ step: "configure" }); - }); -}); - describe("refreshedSourceProfileDirectory", () => { const refreshed = source({ profiles: [ @@ -183,40 +62,3 @@ describe("refreshedSourceProfileDirectory", () => { expect(refreshedSourceProfileDirectory("Removed", source({ profiles: [] }))).toBe(""); }); }); - -describe("isRetryableReason", () => { - it("offers a retry for failures a second attempt can clear", () => { - expect(isRetryableReason("needsKeychainApproval")).toBe(true); - expect(isRetryableReason("keychainUnavailable")).toBe(true); - expect(isRetryableReason("readFailed")).toBe(true); - }); - - it("does not offer a retry for a permanent failure", () => { - expect(isRetryableReason("unsupportedPlatform")).toBe(false); - expect(isRetryableReason("unknownSourceProfile")).toBe(false); - expect(isRetryableReason("profileLimitReached")).toBe(false); - }); - - it("offers a retry once the user has signed in to create the missing key", () => { - expect(isRetryableReason("keychainItemMissing")).toBe(true); - }); - - it("offers a retry when the cookies landed but the new profile was not saved", () => { - expect(isRetryableReason("profileNotSaved")).toBe(true); - }); -}); - -describe("formatSkippedDomains", () => { - it("joins a short list naturally", () => { - expect(formatSkippedDomains([])).toBe(""); - expect(formatSkippedDomains(["a.com"])).toBe("a.com"); - expect(formatSkippedDomains(["a.com", "b.com"])).toBe("a.com and b.com"); - expect(formatSkippedDomains(["a.com", "b.com", "c.com"])).toBe("a.com, b.com and c.com"); - }); - - it("summarizes a long list", () => { - expect(formatSkippedDomains(["a.com", "b.com", "c.com", "d.com", "e.com"])).toBe( - "a.com, b.com, c.com and 2 more", - ); - }); -}); diff --git a/apps/web/src/components/settings/colorPickers.browser.test.tsx b/apps/web/src/components/settings/colorPickers.browser.test.tsx index 69998c90ab0e..1f4915bd9ca7 100644 --- a/apps/web/src/components/settings/colorPickers.browser.test.tsx +++ b/apps/web/src/components/settings/colorPickers.browser.test.tsx @@ -1,35 +1,24 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; +import type { ReactNode } from "react"; + +import { act, render } from "../../../test/render"; import { ThemeColorField } from "./ThemeColorPicker"; const HUE = "Accent color hue"; -const SATURATION = "Accent color saturation"; -const BRIGHTNESS = "Accent color brightness"; const PLANE = "Accent color saturation and brightness"; -let root: Root; -let container: HTMLDivElement; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +async function renderPicker(ui: ReactNode) { + await using stack = new AsyncDisposableStack(); // Synthetic pointer ids are not active pointers, so the browser would reject capture. - vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => {}); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); + stack.use(vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => {})); + const screen = stack.use(await render(ui)); + const environment = stack.move(); + return Object.assign(screen, { + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }); +} function byLabel(label: string) { const element = document.querySelector(`[aria-label="${label}"]`); @@ -38,7 +27,6 @@ function byLabel(label: string) { } const slider = (label: string) => byLabel(label); -const sliderValue = (label: string) => Number(slider(label).value); async function open(triggerLabel: string) { await userEvent.click(byLabel(triggerLabel)); @@ -51,13 +39,6 @@ async function press(label: string, key: string, shift = false) { await userEvent.keyboard(shift ? `{Shift>}{${key}}{/Shift}` : `{${key}}`); } -function setRange(label: string, value: number) { - const input = slider(label); - const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; - setValue.call(input, String(value)); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - function pointer(label: string, type: string, xFraction: number, yFraction = 0.5, pointerId = 1) { const target = byLabel(label); const bounds = target.getBoundingClientRect(); @@ -72,182 +53,23 @@ function pointer(label: string, type: string, xFraction: number, yFraction = 0.5 ); } -const nextFrame = () => - act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - -async function renderAccent(value: string | undefined, commitDelayMs?: number) { - const onCommit = vi.fn(); - await act(async () => - root.render( - , - ), - ); - await open(`${value ? "Change" : "Add"} accent color for Codex`); - return onCommit; -} - async function renderThemeField(value: string) { const onChange = vi.fn(); - const render = (current: string) => - root.render(); - await act(async () => render(value)); + const field = (current: string) => ( + + ); + const screen = await renderPicker(field(value)); await open("Choose Accent color color"); - return { onChange, echo: (next: string) => act(async () => render(next)) }; -} - -describe("provider accent color picker", () => { - it("adjusts each color axis independently from the keyboard", async () => { - const onCommit = await renderAccent("#ff0000"); - - await press(HUE, "ArrowLeft"); - expect(slider(HUE).getAttribute("aria-valuenow")).toBe("359"); - await press(HUE, "ArrowRight"); - expect(onCommit).toHaveBeenLastCalledWith("#ff0000"); - - await press(SATURATION, "ArrowRight", true); - await press(BRIGHTNESS, "ArrowUp", true); - expect(onCommit).toHaveBeenLastCalledWith("#ff0000"); - await press(BRIGHTNESS, "ArrowDown", true); - expect(onCommit).toHaveBeenLastCalledWith("#e60000"); - expect(sliderValue(BRIGHTNESS)).toBe(90); - expect(slider(BRIGHTNESS).getAttribute("aria-valuetext")).toBe("90%"); - expect(sliderValue(SATURATION)).toBe(100); - - await press(SATURATION, "ArrowDown", true); - expect(onCommit).toHaveBeenLastCalledWith("#e61717"); - expect(sliderValue(SATURATION)).toBe(90); - await press(SATURATION, "ArrowUp", true); - expect(onCommit).toHaveBeenLastCalledWith("#e60000"); - await press(BRIGHTNESS, "ArrowLeft", true); - expect(onCommit).toHaveBeenLastCalledWith("#cc0000"); - await press(BRIGHTNESS, "ArrowRight", true); - expect(onCommit).toHaveBeenLastCalledWith("#e60000"); - - onCommit.mockClear(); - await press(SATURATION, "Tab"); - expect(onCommit).not.toHaveBeenCalled(); - }); - - it("supports Home/End and clamps each axis without changing the other", async () => { - const onCommit = await renderAccent("#ff0000"); - - await press(SATURATION, "Home"); - await press(SATURATION, "ArrowLeft"); - expect(sliderValue(SATURATION)).toBe(0); - expect(onCommit).toHaveBeenLastCalledWith("#ffffff"); - await press(BRIGHTNESS, "Home"); - await press(BRIGHTNESS, "ArrowDown"); - expect(sliderValue(BRIGHTNESS)).toBe(0); - expect(onCommit).toHaveBeenLastCalledWith("#000000"); - await press(SATURATION, "End"); - expect(sliderValue(SATURATION)).toBe(100); - expect(sliderValue(BRIGHTNESS)).toBe(0); - await press(BRIGHTNESS, "End"); - await press(BRIGHTNESS, "ArrowUp"); - expect(onCommit).toHaveBeenLastCalledWith("#ff0000"); - await press(SATURATION, "ArrowLeft"); - expect(sliderValue(SATURATION)).toBe(98); - expect(onCommit).toHaveBeenLastCalledWith("#ff0505"); - }); - - it("adds an accent only after choosing one and clears it back to no accent", async () => { - const onCommit = await renderAccent(undefined); - const clearButton = () => - [...document.querySelectorAll("button")].find((button) => - button.textContent?.includes("Clear color"), - ); - expect(onCommit).not.toHaveBeenCalled(); - expect(clearButton()).toBeUndefined(); - - await press(HUE, "ArrowRight", true); - expect(onCommit).toHaveBeenCalledOnce(); - await vi.waitFor(() => expect(clearButton()).toBeDefined()); - - await userEvent.click(clearButton()!); - expect(onCommit).toHaveBeenLastCalledWith(""); - expect(clearButton()).toBeUndefined(); + return Object.assign(screen, { + onChange, + echo: (current: string) => screen.rerender(field(current)), }); - - it("debounces commits and commits the pending color on unmount", async () => { - const onCommit = await renderAccent("#ff0000", 250); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - - await press(HUE, "ArrowRight", true); - await press(HUE, "ArrowRight", true); - expect(onCommit).not.toHaveBeenCalled(); - await act(async () => vi.advanceTimersByTime(250)); - expect(onCommit).toHaveBeenCalledExactlyOnceWith("#ff5500"); - - await press(HUE, "ArrowRight", true); - await act(async () => root.unmount()); - expect(onCommit).toHaveBeenLastCalledWith("#ff8000"); - await act(async () => vi.advanceTimersByTime(250)); - expect(onCommit).toHaveBeenCalledTimes(2); - root = createRoot(container); - }); -}); +} describe("theme color field", () => { - it("batches native range changes into one commit per frame and keeps alpha", async () => { - const { onChange } = await renderThemeField("#ff000080"); - - // Both changes land in one task, so no animation frame can run between them. - await act(async () => { - setRange(SATURATION, 50); - setRange(BRIGHTNESS, 50); - expect(onChange).not.toHaveBeenCalled(); - }); - await nextFrame(); - expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#80404080"); - - await press(BRIGHTNESS, "ArrowDown", true); - await nextFrame(); - expect(onChange).toHaveBeenLastCalledWith("accent", "#66333380"); - expect(sliderValue(SATURATION)).toBe(50); - }); - - it("flushes the final drag color on release of the dragging pointer only", async () => { - const { onChange } = await renderThemeField("#ff000080"); - - // The drag runs in one task, so no animation frame can commit mid-drag. - await act(async () => { - pointer(HUE, "pointerdown", 0.25); - pointer(HUE, "pointermove", 0.5); - pointer(HUE, "pointermove", 0.75, 0.5, 2); - pointer(HUE, "pointerup", 0.75, 0.5, 2); - expect(onChange).not.toHaveBeenCalled(); - pointer(HUE, "pointerup", 0.5); - }); - expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#00ffff80"); - await act(async () => pointer(HUE, "lostpointercapture", 0.5)); - await nextFrame(); - expect(onChange).toHaveBeenCalledOnce(); - }); - - it("clamps out-of-bounds drags and flushes on cancellation and unmount", async () => { - const { onChange } = await renderThemeField("#ff000080"); - - await act(async () => { - pointer(PLANE, "pointerdown", -0.5, -0.5); - pointer(PLANE, "pointercancel", -0.5, -0.5); - }); - expect(onChange).toHaveBeenLastCalledWith("accent", "#ffffff80"); - - await act(async () => { - pointer(PLANE, "pointerdown", 1.5, 1.5); - root.unmount(); - }); - expect(onChange).toHaveBeenLastCalledWith("accent", "#00000080"); - root = createRoot(container); - }); - it("keeps the selected hue when a grey color is echoed back from theme settings", async () => { - const { onChange, echo } = await renderThemeField("#ff0000"); + await using screen = await renderThemeField("#ff0000"); + const { onChange, echo } = screen; await press(HUE, "ArrowRight", true); await act(async () => { diff --git a/apps/web/src/components/settings/customModelEditor.logic.test.ts b/apps/web/src/components/settings/customModelEditor.logic.test.ts index dba4d7af7bbd..a32e1debace8 100644 --- a/apps/web/src/components/settings/customModelEditor.logic.test.ts +++ b/apps/web/src/components/settings/customModelEditor.logic.test.ts @@ -116,32 +116,6 @@ describe("customModelEditor.logic", () => { expect(copied!.choices.map((choice) => choice.id)).toEqual(["high"]); }); - it.each([true, false, undefined])( - "preserves boolean values through copy and edit: %s", - (currentValue) => { - const capabilities: ModelCapabilities = { - optionDescriptors: [ - { - id: "thinking", - label: "Thinking", - type: "boolean", - ...(currentValue !== undefined ? { currentValue } : {}), - }, - ], - }; - const copied = definitionFromDraft( - draft({ - descriptors: descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")), - }), - ); - expect(copied.capabilities).toEqual(capabilities); - const reopened = draftFromDefinition(copied); - expect(definitionFromDraft({ ...reopened, name: "Renamed" }).capabilities).toEqual( - capabilities, - ); - }, - ); - it("excludes Claude context choices from presets and copies without changing other providers or authored entries", () => { const capabilities: ModelCapabilities = { optionDescriptors: [ @@ -175,57 +149,6 @@ describe("customModelEditor.logic", () => { ).toMatchObject(capabilities.optionDescriptors![0]!); }); - it("preserves choice descriptions when copying, renaming, and saving", () => { - const capabilities: ModelCapabilities = { - optionDescriptors: [ - { - id: "effort", - label: "Reasoning", - description: "Choose a reasoning level.", - type: "select", - options: [ - { id: "high", label: "High", isDefault: true }, - { id: "ultracode", label: "Ultracode", description: "Uses additional reasoning." }, - ], - }, - ], - }; - const copied = definitionFromDraft( - draft({ - descriptors: descriptorsFromCapabilities( - capabilities, - ProviderDriverKind.make("claudeAgent"), - ), - }), - ); - const reopened = draftFromDefinition(copied); - const saved = definitionFromDraft({ ...reopened, name: "Renamed" }); - expect(saved.capabilities?.optionDescriptors?.[0]).toMatchObject( - capabilities.optionDescriptors![0]!, - ); - expect(copied.capabilities?.optionDescriptors?.[0]).toMatchObject( - capabilities.optionDescriptors![0]!, - ); - }); - - it.each(Object.entries(DESCRIPTOR_PRESETS_BY_KIND))( - "offers saveable presets for %s", - (_driver, presets) => { - expect( - validateDraft(draft({ descriptors: (presets ?? []).map(descriptorFromPreset) })), - ).toBeNull(); - }, - ); - - it("collapses a blank name and no options back to a bare definition", () => { - expect(definitionFromDraft(draft({ name: " " }))).toEqual({ - slug: "my-model", - name: "my-model", - capabilities: null, - }); - expect(draftFromDefinition({ slug: "x", name: "x", capabilities: null }).name).toBe(""); - }); - it("rejects duplicate ids, blank ids, and selects without choices", () => { const select = (id: string, choices: Array<{ id: string }>) => ({ key: id, diff --git a/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts index 039a3955684c..b9f9cef0c789 100644 --- a/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/deviceHostsSettings.logic.test.ts @@ -17,6 +17,7 @@ describe("device host changes across environments", () => { expect(saved.map((hosts) => updateDeviceHosts(hosts, shared, false))).toEqual(saved); }); + // regression: #11541 device settings edits must apply to selected environments only it("edits and removes the shared host while preserving unrelated hosts", () => { const edited = { ...shared, target: "julius@new-address" }; const saved = [[shared], [local, shared]].map((hosts) => diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts index ed13e95f6786..545d1ac212ec 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts @@ -1,24 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import * as Option from "effect/Option"; -import { - easSessionLimits, - newEasDeviceHost, - parseEasDeviceHostDraft, - updateEasDeviceHosts, -} from "./easDeviceHostsSettings.logic"; +import { newEasDeviceHost, parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; describe("EAS device host settings", () => { - it("adds, edits, and removes one EAS host without touching the others", () => { - const other = newEasDeviceHost("eas-0"); - const eas = newEasDeviceHost("eas-1"); - const added = updateEasDeviceHosts([other], eas, false); - expect(added).toEqual([other, eas]); - const edited = { ...eas, label: "Cloud iPhone", maxConcurrentSessions: 1 }; - expect(updateEasDeviceHosts(added, edited, false)).toEqual([other, edited]); - expect(updateEasDeviceHosts(added, eas, true)).toEqual([other]); - }); - it("rejects limits EAS does not allow", () => { expect(Option.isSome(parseEasDeviceHostDraft(newEasDeviceHost("eas")))).toBe(true); expect( @@ -33,10 +18,4 @@ describe("EAS device host settings", () => { true, ); }); - - it("describes a host's bounds in one line", () => { - expect(easSessionLimits({ ...newEasDeviceHost("eas"), maxConcurrentSessions: 1 })).toBe( - "Up to 1 session at once, each stopped after 30 min or 10 idle min", - ); - }); }); diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts index c165b9401fe4..bc4b9674b674 100644 --- a/apps/web/src/components/settings/providerStatus.test.ts +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -22,13 +22,6 @@ const provider: ServerProvider = { }; describe("getProviderSummary", () => { - it("reports ready providers with unknown authentication as available", () => { - expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ - headline: "Available", - detail: null, - }); - }); - it("does not hide a provider error behind a previous authenticated state", () => { expect( getProviderSummary({ @@ -42,19 +35,6 @@ describe("getProviderSummary", () => { }); }); - it("does not hide a provider warning behind an authenticated state", () => { - expect( - getProviderSummary({ - ...provider, - status: "warning", - message: "The provider version is unsupported.", - }), - ).toEqual({ - headline: "Needs attention", - detail: "The provider version is unsupported.", - }); - }); - it("keeps authentication failures actionable when their provider status is error", () => { expect( getProviderSummary({ @@ -68,10 +48,6 @@ describe("getProviderSummary", () => { detail: "Run codex login.", }); }); - - it("treats a disabled provider status as disabled even before its enabled flag updates", () => { - expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); - }); }); describe("Antigravity with unchecked sign-in", () => { @@ -95,6 +71,7 @@ describe("Antigravity with unchecked sign-in", () => { } }); + // regression: #18623 neutral unchecked-sign-in display must not mask real warnings it("preserves actionable warnings, failures and disabled states outside unchecked Google sign-in", () => { const cases = [ { @@ -256,20 +233,3 @@ it("shows compatibility in the version popover even when the installed version i targetVersion: null, }); }); - -describe("provider status copy", () => { - it("surfaces an ACP-advertised authentication method", () => { - const provider = { - enabled: true, - installed: true, - status: "error", - auth: { status: "unauthenticated", type: "agent", label: "Company login" }, - message: "Complete this authentication method on the server.", - } as ServerProvider; - - expect(getProviderSummary(provider)).toEqual({ - headline: "Not authenticated · Company login", - detail: "Complete this authentication method on the server.", - }); - }); -}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 9351d7692713..11a6ddf524d0 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -120,14 +120,6 @@ describe("scheduled task settings scope", () => { ), ).toBe(false); }); - - it("does not offer an unrelated environment's same-ID project when creating a task", () => { - const scope = resolveSettingsScope({ project: "t3code" }, groups, environments); - const serverProjects = [third, other, sameIdElsewhere]; - expect( - serverProjects.filter((project) => matchesScheduledTaskScope(scope, serverId, project.id)), - ).toEqual([third]); - }); }); const legacyTask: ScheduledTask = { @@ -159,14 +151,6 @@ describe("editing scheduled task branch settings", () => { expect(draft.baseRef).toBe("release"); expect(draft.startFromOrigin).toBe(false); }); - - it.each([true, false])("preserves an explicit origin flag of %s", (startFromOrigin) => { - const draft = taskToDraft({ - ...legacyTask, - workspaceStrategy: { type: "worktree", baseRef: "release", startFromOrigin }, - }); - expect(draft.startFromOrigin).toBe(startFromOrigin); - }); }); describe("catching up missed fixed-time runs", () => { @@ -202,6 +186,43 @@ describe("webhook scheduled tasks", () => { webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true }, }; + it("round-trips dedupe settings, clears them, and rejects invalid windows", () => { + const dedupe = { + replayKey: "{{headers.delivery-id}}", + coalesceKey: "{{body.id}}", + coalesceWindowSeconds: 10, + }; + const draft = taskToDraft({ + ...webhookTask, + schedule: { type: "webhook", signature: null, dedupe }, + }); + expect(scheduleFromDraft(draft)).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: " \n ", + coalesceKey: " \t ", + coalesceWindowSeconds: "", + }), + ).toMatchObject({ dedupe: {} }); + expect( + scheduleFromDraft({ + ...draft, + replayKey: ` ${dedupe.replayKey} `, + coalesceKey: `\t${dedupe.coalesceKey}\n`, + }), + ).toMatchObject({ dedupe }); + expect( + scheduleFromDraft({ ...draft, replayKey: "", coalesceKey: "", coalesceWindowSeconds: "" }), + ).toMatchObject({ dedupe: {} }); + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds: "" })).toMatchObject({ + dedupe: { replayKey: dedupe.replayKey, coalesceKey: dedupe.coalesceKey }, + }); + for (const coalesceWindowSeconds of ["9", "3601", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, coalesceWindowSeconds })).toBeNull(); + } + }); + it("keeps a stored secret when the secret field is left blank", () => { const draft = taskToDraft(webhookTask); expect(draft.scheduleMode).toBe("webhook"); @@ -210,22 +231,13 @@ describe("webhook scheduled tasks", () => { type: "webhook", signature, maxDeliveryAgeMinutes: null, + dedupe: {}, }); expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({ type: "webhook", signature: { ...signature, secret: "new" }, maxDeliveryAgeMinutes: null, - }); - }); - - it("drops the signature when it is switched off and offers GitHub's settings", () => { - const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } }); - expect(draft.signatureEnabled).toBe(false); - expect(draft.signatureHeader).toBe("x-hub-signature-256"); - expect(scheduleFromDraft(draft)).toEqual({ - type: "webhook", - signature: null, - maxDeliveryAgeMinutes: null, + dedupe: {}, }); }); @@ -239,12 +251,38 @@ describe("webhook scheduled tasks", () => { for (const blank of ["", " "]) { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: blank })).toMatchObject({ maxDeliveryAgeMinutes: null, + dedupe: {}, }); } for (const invalid of ["0", "-5", "1.5", "abc", "1441"]) { expect(scheduleFromDraft({ ...draft, maxDeliveryAgeMinutes: invalid })).toBeNull(); } }); + + it("sends filters only when the form changed them", () => { + const filters = [ + { path: "headers.x-github-event", op: "equals", value: "pull_request" }, + { path: "body.action", op: "in", value: ["opened", "reopened"] }, + ] as const; + const draft = taskToDraft({ + ...webhookTask, + schedule: { type: "webhook", signature, filters }, + }); + expect(draft.filters.map((filter) => filter.op)).toEqual(["equals", "in"]); + // A title-only save leaves the key out, so the server keeps whatever is stored now. + expect(scheduleFromDraft({ ...draft, title: "Renamed" })).not.toHaveProperty("filters"); + expect(scheduleFromDraft({ ...draft, filters: draft.filters.slice(0, 1) })).toMatchObject({ + filters: filters.slice(0, 1), + }); + // Removing every row is a change, so it clears the stored filters. + expect(scheduleFromDraft({ ...draft, filters: [] })).toMatchObject({ filters: [] }); + expect( + scheduleFromDraft({ + ...draft, + filters: [{ id: "f1", path: "body.action", op: "in", value: " , " }], + }), + ).toBeNull(); + }); }); describe("scheduled task model defaults", () => { @@ -291,28 +329,6 @@ describe("scheduled task model defaults", () => { project, deriveProviderInstanceEntries(value.providers), ); - it("uses the environment default with its provider options", () => { - expect(resolve(config, { id: projectId })).toEqual(environmentSelection); - }); - it("prefers the project's configured model", () => { - expect(resolve(config, { id: projectId, defaultModelSelection: projectSelection })).toEqual( - projectSelection, - ); - expect( - resolve( - { - ...config, - settings: { - ...config.settings, - projectSettingsOverrides: { - [projectId]: { defaultModelSelection: projectSelection }, - }, - }, - }, - { id: projectId }, - ), - ).toEqual(projectSelection); - }); it("uses the advertised default instead of catalog order when no default is configured", () => { expect( resolve({ ...config, settings: { ...config.settings, defaultModelSelection: null } }, null), @@ -329,17 +345,12 @@ describe("scheduled task model defaults", () => { }), ).toEqual(environmentSelection); }); - it("does not choose an implicit model on a disabled provider", () => { - expect( - resolve( - { - ...config, - providers: config.providers.map((provider) => ({ ...provider, enabled: false })), - }, - null, - ), - ).toBeNull(); - }); +}); + +it("rejects dedupe templates without a request placeholder", () => { + const draft = taskToDraft({ ...legacyTask, schedule: { type: "webhook", signature: null } }); + expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); + expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); describe("starting a scheduled task from a composer draft", () => { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 7cdba39b467f..11d7b8c00655 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -1,5 +1,7 @@ +import { isWebhookDedupeTemplate } from "@t3tools/contracts"; import { EnvironmentId, + MAX_WEBHOOK_DELIVERY_AGE_MINUTES, type ProjectId, ScheduledTaskId, type ScheduledTask, @@ -9,8 +11,14 @@ import { type ProviderInteractionMode, type ServerSettings, } from "@t3tools/contracts"; -import { parseMaxDeliveryAge } from "@t3tools/client-runtime/scheduled-task-webhook"; import { scheduledTaskPromptFromComposer } from "@t3tools/client-runtime/scheduled-task-picker"; +import { + parseMaxDeliveryAge, + parseWebhookFilterDrafts, + webhookFilterDrafts, + webhookFilterDraftsChanged, + type WebhookFilterDraft, +} from "@t3tools/client-runtime/scheduled-task-webhook"; import { resolveProjectSettings, @@ -79,6 +87,12 @@ export interface DraftState { /** Write-only: empty keeps the secret already stored on the server. */ readonly signatureSecret: string; readonly maxDeliveryAgeMinutes: string; + readonly replayKey: string; + readonly coalesceKey: string; + readonly coalesceWindowSeconds: string; + readonly filters: ReadonlyArray; + /** The filter rows the form opened with. */ + readonly savedFilters: ReadonlyArray; } export const WEBHOOK_SIGNATURE_DEFAULTS = { @@ -87,10 +101,65 @@ export const WEBHOOK_SIGNATURE_DEFAULTS = { signaturePrefix: "sha256=", } as const; +export function webhookDraftErrors(draft: DraftState) { + if (draft.scheduleMode !== "webhook") return []; + if (parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes) === undefined) + return [ + { + title: "Invalid age limit", + description: `Enter whole minutes from 1 to ${MAX_WEBHOOK_DELIVERY_AGE_MINUTES}, or leave it blank.`, + }, + ]; + const errors: Array<{ title: string; description: string }> = []; + for (const [label, value] of [ + ["Replay key", draft.replayKey], + ["Coalesce key", draft.coalesceKey], + ] as const) { + if (value.trim() && !isWebhookDedupeTemplate(value.trim())) + errors.push({ + title: "Invalid webhook settings", + description: `${label} must contain a valid request placeholder.`, + }); + } + const window = draft.coalesceWindowSeconds.trim(); + if (window && (!Number.isInteger(Number(window)) || Number(window) < 10 || Number(window) > 3600)) + errors.push({ + title: "Invalid webhook settings", + description: "Coalesce window must be whole seconds from 10 to 3600.", + }); + return errors; +} + +export function webhookDeliveryLabel(delivery: { + readonly outcome: string; + readonly pendingWindow?: boolean | undefined; +}) { + if (delivery.outcome === "accepted") return delivery.pendingWindow ? "Opened window" : "Ran"; + const labels: Record = { + dispatch_failed: "Run failed", + duplicate: "Duplicate", + coalesced: "Coalesced", + filtered: "Filtered", + rejected_signature: "Bad signature", + disabled: "Task paused", + rate_limited: "Rate limited", + expired: "Too old", + }; + return labels[delivery.outcome] ?? delivery.outcome.replaceAll("_", " "); +} + export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null { if (draft.scheduleMode === "webhook") { + if (webhookDraftErrors(draft).length > 0) return null; const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); + const replayKey = draft.replayKey.trim(); + const coalesceKey = draft.coalesceKey.trim(); + const coalesceWindowSeconds = draft.coalesceWindowSeconds.trim() + ? Number(draft.coalesceWindowSeconds) + : undefined; if (maxDeliveryAgeMinutes === undefined) return null; + const parsedFilters = parseWebhookFilterDrafts(draft.filters); + if (!("filters" in parsedFilters)) return null; const secret = draft.signatureSecret.trim(); return { type: "webhook", @@ -103,6 +172,19 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul } : null, maxDeliveryAgeMinutes, + ...(replayKey || coalesceKey || coalesceWindowSeconds !== undefined + ? { + dedupe: { + ...(replayKey ? { replayKey } : {}), + ...(coalesceKey ? { coalesceKey } : {}), + ...(coalesceWindowSeconds === undefined ? {} : { coalesceWindowSeconds }), + }, + } + : { dedupe: {} }), + // Only edited filters are sent; the server keeps the stored ones otherwise. + ...(webhookFilterDraftsChanged(draft.savedFilters, draft.filters) + ? { filters: parsedFilters.filters } + : {}), }; } if (draft.scheduleMode === "interval") { @@ -120,6 +202,7 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul export function taskToDraft(task: ScheduledTask): DraftState { const schedule = task.schedule; + const filters = webhookFilterDrafts(schedule.type === "webhook" ? schedule.filters : undefined); const weekdays = schedule.type === "fixed_time" && schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) @@ -161,10 +244,18 @@ export function taskToDraft(task: ScheduledTask): DraftState { } : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }), signatureSecret: "", + replayKey: schedule.type === "webhook" ? (schedule.dedupe?.replayKey ?? "") : "", + coalesceKey: schedule.type === "webhook" ? (schedule.dedupe?.coalesceKey ?? "") : "", + coalesceWindowSeconds: + schedule.type === "webhook" && schedule.dedupe?.coalesceWindowSeconds !== undefined + ? String(schedule.dedupe.coalesceWindowSeconds) + : "", maxDeliveryAgeMinutes: schedule.type === "webhook" && schedule.maxDeliveryAgeMinutes != null ? String(schedule.maxDeliveryAgeMinutes) : "", + filters, + savedFilters: filters, }; } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 3bad4e94a6f4..c03e466d1e24 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -13,7 +13,6 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; import { - listProjectOverrides, persistScopedSettingsPatch, planProjectOverridesClear, planScopedSettingsClear, @@ -124,13 +123,6 @@ describe("scoped settings targets", () => { }); }); - it("prefers the selected primary as the aggregate representative without including disconnected targets", () => { - const selected = selectScopedSettingsEnvironments(all, environments, server.environmentId); - expect(selected.environment).toBe(server); - expect(selected.environments).toEqual(environments); - expect(selected.connectedEnvironments).toEqual([laptop, server]); - }); - it("resolves each member's effective settings and source at project scope", () => { const overridden = environment("Server", { settings: { @@ -199,24 +191,6 @@ describe("scoped settings writes", () => { }, ]); }); - it("isolates a formerly shared server preference to the named environment", async () => { - const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" }); - const persistClient = vi.fn(); - await persistScopedSettingsPatch( - planScopedSettingsPatch(named, environments, { sidebarAutoSettleOnMerge: false }), - persistServer, - persistClient, - ); - expect(persistServer.mock.calls).toEqual([ - [ - { - environmentId: server.environmentId, - input: { patch: { sidebarAutoSettleOnMerge: false } }, - }, - ], - ]); - expect(persistClient).not.toHaveBeenCalled(); - }); it("writes an aggregate preference only to connected environments with loaded configuration", async () => { const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" }); @@ -277,6 +251,7 @@ describe("scoped settings writes", () => { ).toEqual({ saved: [laptop.label], failed: unsupported }); }); + // regression: #18334 an environment silently dropped a setting with no warning it("reports a dropped key inside a project override", async () => { expect( await outcome( @@ -288,23 +263,6 @@ describe("scoped settings writes", () => { ).toEqual({ saved: [laptop.label], failed: unsupported }); }); - it("reports a nested key dropped beside one that saved", async () => { - const patch = { defaultAutoPull: true, enableAgentBrowserAccess: false }; - const persistServer = vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({ - _tag: "Success" as const, - value: { - ...DEFAULT_SERVER_SETTINGS, - projectSettingsOverrides: - environmentId === laptop.environmentId - ? { [laptopProjectId]: patch } - : { [projectId]: { defaultAutoPull: true } }, - }, - })); - expect( - await outcome(planScopedSettingsPatch(project, environments, patch), persistServer), - ).toEqual({ saved: [laptop.label], failed: unsupported }); - }); - it("accepts a write that matches what the environment already had", async () => { expect( await outcome( @@ -451,6 +409,7 @@ describe("scoped settings writes", () => { ).toEqual([]); }); + // regression: #11541 device settings were written to the wrong scope it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => { const plan = planScopedSettingsPatch(project, [laptop, server], { enableAgentDeviceAccess: true, @@ -553,14 +512,6 @@ describe("scoped settings writes", () => { }); describe("scoped settings mixed values", () => { - it("compares only requested settings across connected targets", () => { - const changed = environment("Changed", { settings: { enableAgentBrowserAccess: false } }); - const targets = resolveScopedSettingsTargets(all, [laptop, changed]); - expect(scopedSettingsAreMixed(targets, ["enableAgentBrowserAccess"])).toBe(true); - expect(scopedSettingsAreMixed(targets, ["enableProviderUpdateChecks"])).toBe(false); - expect(scopedSettingsAreMixed([], ["enableAgentBrowserAccess"])).toBe(false); - }); - it("treats independently decoded equal nested settings as the same value", () => { const style = { ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle, @@ -576,7 +527,6 @@ describe("scoped settings mixed values", () => { describe("project overrides at environment scope", () => { const laptop = EnvironmentId.make("laptop"); - const desk = EnvironmentId.make("desk"); const fleet = ProjectId.make("fleet"); const t3 = ProjectId.make("t3"); const environment = ( @@ -592,23 +542,6 @@ describe("project overrides at environment scope", () => { }, }); - it("lists only the projects that override the keys", () => { - const entries = listProjectOverrides( - [ - environment(laptop, { - [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" }, - [t3]: { defaultThreadEnvMode: "local" }, - }), - environment(desk, { [fleet]: { defaultAutoPull: false } }), - ], - ["defaultAutoPull"], - ); - expect(entries).toEqual([ - { environmentId: laptop, projectId: fleet }, - { environmentId: desk, projectId: fleet }, - ]); - }); - it("clears only those keys and drops entries that become empty", () => { const plan = planProjectOverridesClear( [ @@ -786,6 +719,7 @@ describe("model selection patches at project scope", () => { }, }); + // regression: #16581 changing a project's model kept the old model's options it("replaces old options when changing model selections", () => { const plan = planScopedSettingsPatch(checkout, [customized], { defaultModelSelection: { instanceId: claude, model: "claude-opus-4-8" }, diff --git a/apps/web/src/components/settings/settingsLayout.browser.test.tsx b/apps/web/src/components/settings/settingsLayout.browser.test.tsx deleted file mode 100644 index b44b0cb56bf8..000000000000 --- a/apps/web/src/components/settings/settingsLayout.browser.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { scrollToSettingsTarget } from "./settingsLayout"; - -const PULSE = "settings-search-target-pulse"; - -afterEach(() => { - document.body.replaceChildren(); - vi.restoreAllMocks(); -}); - -function mount(html: string) { - document.body.innerHTML = html; - return vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {}); -} - -function reduceMotion(matches: boolean) { - vi.spyOn(window, "matchMedia").mockReturnValue({ matches } as MediaQueryList); -} - -describe("settings search targets", () => { - it("scrolls a section's header into view, focuses the section, and pulses until blur", () => { - reduceMotion(false); - const scrollIntoView = mount( - `

Providers

Body

`, - ); - const section = document.getElementById("providers")!; - - expect(scrollToSettingsTarget("providers")).toBe(true); - expect(scrollIntoView).toHaveBeenCalledOnce(); - expect(scrollIntoView.mock.contexts[0]).toBe(section.querySelector("h2")); - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "center" }); - expect(document.activeElement).toBe(section); - expect(section.classList.contains(PULSE)).toBe(true); - - section.blur(); - expect(section.classList.contains(PULSE)).toBe(false); - }); - - it("restarts the pulse when the same destination is chosen again", () => { - reduceMotion(false); - mount(`
`); - const target = document.getElementById("word-wrap")!; - const observer = new MutationObserver(() => {}); - observer.observe(target, { attributeFilter: ["class"], attributeOldValue: true }); - - scrollToSettingsTarget("word-wrap"); - const previousClasses = observer.takeRecords().map((record) => record.oldValue); - expect(previousClasses).toEqual([PULSE, ""]); - expect(target.classList.contains(PULSE)).toBe(true); - }); - - it("does not animate the destination when reduced motion is requested", () => { - reduceMotion(true); - const scrollIntoView = mount(`
`); - const target = document.getElementById("word-wrap")!; - - expect(scrollToSettingsTarget("word-wrap")).toBe(true); - expect(scrollIntoView.mock.contexts[0]).toBe(target); - expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); - expect(document.activeElement).toBe(target); - expect(target.classList.contains(PULSE)).toBe(false); - }); - - it.each(["before", "after", "without animation"] as const)( - "fully reveals the row when the opening style clears: %s", - async (phase) => { - // Keep scrolling synchronous while the panel height transitions independently. - reduceMotion(true); - document.body.innerHTML = ` -
-
-
-
Legacy sidebar
-
-
-
`; - const viewport = document.getElementById("viewport")!; - const panel = document.getElementById("panel")!; - const target = document.getElementById("legacy-sidebar")!; - - void panel.offsetHeight; - const animated = phase !== "without animation"; - if (animated) { - panel.style.height = "600px"; - } else { - panel.style.transition = "none"; - } - const transition = panel.getAnimations()[0]; - if (animated) { - expect(transition).toBeDefined(); - transition!.pause(); - transition!.currentTime = phase === "before" ? 0 : 100; - if (phase === "after") panel.removeAttribute("data-starting-style"); - } - - expect(scrollToSettingsTarget("legacy-sidebar")).toBe(true); - expect(document.activeElement).toBe(target); - const opened = animated - ? new Promise((resolve) => { - panel.addEventListener("transitionend", () => resolve(), { once: true }); - }) - : Promise.resolve(); - panel.style.height = "600px"; - panel.removeAttribute("data-starting-style"); - transition?.finish(); - await opened; - - const bounds = viewport.getBoundingClientRect(); - const row = target.getBoundingClientRect(); - expect(row.top).toBeGreaterThanOrEqual(bounds.top); - expect(row.bottom).toBeLessThanOrEqual(bounds.bottom); - expect(document.activeElement).toBe(target); - }, - ); - - it("keeps the newer search destination visible when an earlier panel finishes opening", async () => { - reduceMotion(true); - document.body.innerHTML = ` -
-
New target
-
-
-
-
Legacy sidebar
-
-
-
`; - const panel = document.getElementById("panel")!; - void panel.offsetHeight; - panel.style.height = "600px"; - const transition = panel.getAnimations()[0]!; - transition.pause(); - transition.currentTime = 100; - - scrollToSettingsTarget("legacy-sidebar"); - panel.removeAttribute("data-starting-style"); - scrollToSettingsTarget("new-target"); - const opened = new Promise((resolve) => { - panel.addEventListener("transitionend", () => resolve(), { once: true }); - }); - transition.finish(); - await opened; - - const viewport = document.getElementById("viewport")!; - const target = document.getElementById("new-target")!; - expect(target.getBoundingClientRect().top).toBeGreaterThanOrEqual( - viewport.getBoundingClientRect().top, - ); - expect(target.getBoundingClientRect().bottom).toBeLessThanOrEqual( - viewport.getBoundingClientRect().bottom, - ); - expect(document.activeElement).toBe(target); - }); - - it("leaves not-yet-mounted destinations to their mount lifecycle", () => { - expect(scrollToSettingsTarget("archive")).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts index 0199f6eb6cb9..84a70a4faf5d 100644 --- a/apps/web/src/components/settings/settingsScope.test.ts +++ b/apps/web/src/components/settings/settingsScope.test.ts @@ -60,22 +60,6 @@ function group( const groups = [group("t3code", [first, second, third]), group("other", [other])]; describe("settings scope search", () => { - it("ignores the retired scope key from older links", () => { - expect(validateSettingsScopeSearch({ scope: "device", project: "t3code" })).toEqual({ - project: "t3code", - }); - expect(validateSettingsScopeSearch({ scope: "all" })).toEqual({}); - }); - - it("retains legacy project and machine links without inventing an explicit broad scope", () => { - expect( - validateSettingsScopeSearch({ project: "t3code", machine: laptopId, unused: true }), - ).toEqual({ - project: "t3code", - machine: laptopId, - }); - }); - it("retains an orphan checkout so it cannot turn into all environments", () => { const search = validateSettingsScopeSearch({ checkout: first.physicalProjectKey }); expect(search).toEqual({ checkout: first.physicalProjectKey }); @@ -89,23 +73,6 @@ describe("settings scope search", () => { }); describe("settings scope resolution", () => { - it("defaults to every environment with no project", () => { - expect(resolveSettingsScope({}, groups, environments)).toMatchObject({ - kind: "all", - members: [], - environmentIds: [laptopId, serverId], - }); - }); - - it("resolves one environment without targeting its project overrides", () => { - expect(resolveSettingsScope({ machine: serverId }, groups, environments)).toMatchObject({ - kind: "environment", - environmentId: serverId, - environmentIds: [serverId], - members: [], - }); - }); - it("keeps all physical members in a project aggregate, including several on one environment", () => { expect(resolveSettingsScope({ project: "t3code" }, groups, environments)).toMatchObject({ kind: "project", @@ -115,18 +82,6 @@ describe("settings scope resolution", () => { }); }); - it("preserves legacy project plus machine aggregates with multiple checkouts", () => { - expect( - resolveSettingsScope({ project: "t3code", machine: laptopId }, groups, environments), - ).toMatchObject({ - kind: "project", - environmentId: laptopId, - label: "t3code / Laptop", - members: [first, second], - environmentIds: [laptopId], - }); - }); - it("narrows a checkout target to exactly one member, deriving its environment when omitted", () => { expect( resolveSettingsScope( diff --git a/apps/web/src/components/settings/settingsScopeAxis.test.ts b/apps/web/src/components/settings/settingsScopeAxis.test.ts index 5954a21ecfa3..ab919d7cbe05 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.test.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.test.ts @@ -1,73 +1,8 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; -import { - environmentAxisValue, - projectAxisValue, - projectScopeMenuGroups, - selectEnvironmentAxis, - selectProjectAxis, - settingsScopeEnvironmentLabel, -} from "./settingsScopeAxis"; - -const first = { - environmentId: EnvironmentId.make("first"), - label: "Development", - displayUrl: "https://first.example.com", -}; -const second = { - environmentId: EnvironmentId.make("second"), - label: "Development", - displayUrl: "https://second.example.com", -}; - -describe("settings scope environment labels", () => { - it("distinguishes same-name environments by address", () => { - const environments = [first, second]; - expect( - environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), - ).toEqual(["Development · first.example.com", "Development · second.example.com"]); - }); - - it("falls back to environment IDs when duplicate names have no display URL", () => { - const environments = [first, second].map((environment) => ({ - ...environment, - displayUrl: null, - })); - expect( - environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), - ).toEqual(["Development · first", "Development · second"]); - }); - - it("keeps unique names compact and removes disambiguation after a rename", () => { - expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); - expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( - "Development", - ); - }); -}); +import { selectEnvironmentAxis, selectProjectAxis } from "./settingsScopeAxis"; describe("settings scope axes", () => { - it("maps each axis to its search key and back", () => { - expect(projectAxisValue({})).toBe("all"); - expect(projectAxisValue({ project: "app" })).toBe("app"); - expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({ - project: "app", - machine: "second", - }); - expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({ - machine: "second", - }); - expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({ - project: "app", - machine: "first", - }); - expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({ - project: "app", - }); - }); - it("drops a checkout narrowing from older links when either axis changes", () => { const checkout = { project: "app", checkout: "app@first", machine: "first" }; expect(selectEnvironmentAxis(checkout, "second")).toEqual({ @@ -77,62 +12,3 @@ describe("settings scope axes", () => { expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" }); }); }); - -describe("environmentAxisValue", () => { - it("shows the checkout's environment for a legacy checkout link", () => { - expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop"); - expect(environmentAxisValue({ project: "p" }, null)).toBe("all"); - expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); - }); -}); - -describe("settings project picker", () => { - function group( - projectKey: string, - environmentIds: readonly [EnvironmentId, ...EnvironmentId[]], - ): SidebarProjectSnapshot { - const members = environmentIds.map((environmentId) => ({ - id: ProjectId.make(`${projectKey}-${environmentId}`), - environmentId, - title: projectKey, - workspaceRoot: `/repos/${projectKey}`, - physicalProjectKey: `${environmentId}:/repos/${projectKey}`, - environmentLabel: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-10-19T00:00:00.000Z", - updatedAt: "2026-10-19T00:00:00.000Z", - })); - return { - ...members[0]!, - projectKey, - displayName: projectKey, - memberProjects: members, - memberProjectRefs: members.map((member) => ({ - environmentId: member.environmentId, - projectId: member.id, - })), - groupedProjectCount: members.length, - environmentPresence: environmentIds.length > 1 ? "mixed" : "local-only", - allRemoteMembersAreDesktopLocal: false, - allRemoteMembersAreWsl: false, - remoteEnvironmentLabels: [], - }; - } - - it("offers groups with any checkout on the chosen environment, or all groups without one", () => { - const laptop = EnvironmentId.make("laptop"); - const server = EnvironmentId.make("server"); - const local = group("local", [laptop]); - const shared = group("shared", [laptop, server]); - const remote = group("remote", [server]); - const groups = [local, shared, remote]; - - expect(projectScopeMenuGroups(groups, { machine: server })).toEqual([shared, remote]); - expect(projectScopeMenuGroups(groups, { machine: laptop })).toEqual([local, shared]); - expect(projectScopeMenuGroups(groups, { machine: "unknown" })).toEqual([]); - expect(projectScopeMenuGroups(groups, {})).toEqual(groups); - expect(projectScopeMenuGroups([], { machine: server })).toEqual([]); - expect(projectScopeMenuGroups([], {})).toEqual([]); - }); -}); diff --git a/apps/web/src/components/settings/settingsScopeNavigation.test.ts b/apps/web/src/components/settings/settingsScopeNavigation.test.ts index 53a86643cda7..a89468eb80d9 100644 --- a/apps/web/src/components/settings/settingsScopeNavigation.test.ts +++ b/apps/web/src/components/settings/settingsScopeNavigation.test.ts @@ -81,19 +81,6 @@ function createSettingsRouter(initialEntry = "/settings/general") { } describe("settings scope navigation", () => { - it("replaces the default scope with an explicit environment, then replaces it with a project", async () => { - const router = createSettingsRouter(); - await router.load(); - await router.navigate({ - to: "/settings/general", - search: { machine: "remote-server" }, - hash: "", - }); - expect(router.state.location.search).toEqual({ machine: "remote-server" }); - await router.navigate({ to: "/settings/projects", search: { project: "another-project" } }); - expect(router.state.location.search).toEqual({ project: "another-project" }); - }); - it("clears a checkout when selecting all environments and all projects", async () => { const router = createSettingsRouter(); await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" }); @@ -216,16 +203,6 @@ describe("settings scope navigation", () => { }); }); - it("respects explicit clearing keys instead of restoring the previous checkout", async () => { - const router = createSettingsRouter(); - await router.navigate({ to: "/settings/general", search: checkoutSearch }); - await router.navigate({ - to: "/settings/projects", - search: { project: "different-project", machine: undefined }, - }); - expect(router.state.location.search).toEqual({ project: "different-project" }); - }); - it("preserves escaped checkout identifiers across reload and browser history", async () => { const router = createSettingsRouter(); await router.navigate({ to: "/settings/general", search: checkoutSearch }); @@ -286,11 +263,4 @@ describe("scheduled task scope navigation", () => { }); expect(router.state.matches.at(-1)?.search).toEqual({}); }); - - it("keeps the project and checkout filters when entering scheduled tasks", async () => { - const router = createSettingsRouter(); - await router.navigate({ to: "/settings/projects", search: checkoutSearch }); - await router.navigate({ to: "/settings/scheduled-tasks" }); - expect(router.state.matches.at(-1)?.search).toEqual(checkoutSearch); - }); }); diff --git a/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx b/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx deleted file mode 100644 index 8c029938b17d..000000000000 --- a/apps/web/src/components/settings/settingsScopeRetention.browser.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { act, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import type { SettingsScopeSearch } from "./settingsScope"; - -interface ScopeState { - readonly search: SettingsScopeSearch; - readonly environmentId: string | null; -} - -const state = vi.hoisted(() => ({ - pathname: "/settings/diagnostics", - scope: { search: {}, environmentId: null } as ScopeState, -})); - -vi.mock(import("@tanstack/react-router"), async (original) => ({ - ...(await original()), - createFileRoute: (() => (options: { component: () => ReactNode }) => ({ - options, - useSearch: () => state.scope.search, - useNavigate: () => vi.fn(), - })) as never, - useLocation: ((options?: { select: (location: unknown) => unknown }) => { - const location = { pathname: state.pathname, hash: "", state: {} }; - return options ? options.select(location) : location; - }) as never, - Outlet: (() => ) as never, -})); -vi.mock(import("./SettingsScopeContext"), async (original) => { - const { createContext, useContext } = await import("react"); - const Scope = createContext(state.scope); - return { - ...(await original()), - SettingsScopeProvider: (({ children }: { children: ReactNode }) => ( - {children} - )) as never, - useSettingsScope: (() => { - const { search, environmentId } = useContext(Scope); - return { - search, - scope: { kind: "all" }, - environment: environmentId ? { environmentId } : null, - connectedEnvironments: environmentId ? [{}] : [], - }; - }) as never, - }; -}); -vi.mock(import("../../state/environments"), async (original) => ({ - ...(await original()), - useEnvironments: (() => ({ environments: [] })) as never, -})); -vi.mock(import("../ui/sidebar"), async (original) => ({ - ...(await original()), - SidebarInset: ({ children }: { children?: ReactNode }) =>
{children}
, -})); -vi.mock(import("../WorkspacePageHeader"), async (original) => ({ - ...(await original()), - WorkspacePageHeader: ({ children }: { children?: ReactNode }) =>
{children}
, -})); -vi.mock(import("./SettingsBreadcrumb"), async (original) => ({ - ...(await original()), - SettingsBreadcrumb: (() => null) as never, -})); -vi.mock(import("../sidebar/mainAppLocation"), async (original) => ({ - ...(await original()), - useNavigateToMainApp: () => vi.fn(), -})); -vi.mock(import("../../hooks/useNavigateBack"), async (original) => ({ - ...(await original()), - useEscapeToGoBack: () => undefined, -})); -vi.mock(import("./SettingsPanels"), async (original) => ({ - ...(await original()), - useSettingsRestore: (() => ({ changedSettingLabels: [] })) as never, -})); - -import { Route } from "../../routes/settings"; - -function StatefulContent() { - const [value, setValue] = useState(0); - return ( - - ); -} - -let root: Root; -let container: HTMLDivElement; -const Layout = Route.options.component!; -await Layout.preload?.(); - -async function render(scope: ScopeState) { - state.scope = scope; - await act(async () => root.render()); -} -function retainedValue() { - return container.querySelector('button[aria-label="Retained value"]')?.textContent ?? null; -} - -beforeEach(() => { - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - -const first = { search: { machine: "first", project: "one" }, environmentId: "first" }; -const otherProject = { search: { machine: "first", project: "two" }, environmentId: "first" }; -const otherMachine = { search: { machine: "second", project: "one" }, environmentId: "second" }; -const implicitFirst = { search: { project: "one" }, environmentId: "first" }; -const implicitSecond = { search: { project: "one" }, environmentId: "second" }; - -const disconnected = { search: first.search, environmentId: null }; - -it.each([ - ["/settings/diagnostics", "another project", "1", first, otherProject], - ["/settings/providers", "another project", "1", first, otherProject], - ["/settings/diagnostics", "another environment", "0", first, otherMachine], - ["/settings/providers", "another environment", "0", first, otherMachine], - ["/settings/diagnostics", "an implicit environment change", "0", implicitFirst, implicitSecond], - ["/settings/providers", "an implicit environment change", "0", implicitFirst, implicitSecond], - ["/settings/diagnostics", "a disconnect", "0", first, disconnected], - ["/settings/keybindings", "another project", "0", first, otherProject], -])("%s after %s shows %s", async (pathname, _change, expected, before, after) => { - state.pathname = pathname; - await render(before); - await act(async () => - container.querySelector('button[aria-label="Retained value"]')?.click(), - ); - expect(retainedValue()).toBe("1"); - - await render(after); - - expect(retainedValue()).toBe(expected); -}); diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index d7aeda16c3ce..0aec865ebf17 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -6,7 +6,6 @@ import { getSettingsSearchTargetScope, getThreadAutoSettlementSearchAvailability, isSettingsSearchScopeAvailable, - searchableSetting, searchSettings, SETTINGS_SEARCH_ITEMS, type SettingsSearchItem, @@ -44,23 +43,6 @@ const ITEMS: ReadonlyArray = [ ]; describe("searchSettings", () => { - it.each(["OTel", "OTLP", "telemetry export", "Grafana"])( - "finds environment telemetry settings for %s", - (query) => { - expect(searchSettings(query)).toContainEqual( - expect.objectContaining({ - id: "telemetry-export", - to: "/settings/diagnostics", - targetId: "telemetry-export", - scope: "environment", - }), - ); - }, - ); - it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => { - expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut"); - }); - it("matches titles, sections, and remembered setting details", () => { expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]); @@ -84,13 +66,6 @@ describe("searchSettings", () => { expect(searchSettings("xyzzy")).toEqual([]); }); - it("keeps catalog order for multiple title matches", () => { - expect(searchSettings("update", ITEMS).map((item) => item.id)).toEqual([ - "provider-updates", - "automatic-updates", - ]); - }); - it("matches query words across fields and ranks the strongest result first", () => { expect(searchSettings("pairing remote", ITEMS).map((item) => item.id)).toEqual([ "network-access", @@ -102,40 +77,6 @@ describe("searchSettings", () => { ).toEqual(["network-access", "connections-environment"]); }); - it("finds settings that used to be reachable only through their section", () => { - expect(searchSettings("pull request template")[0]?.id).toBe("follow-change-request-templates"); - expect(searchSettings("git security keys")[0]?.id).toBe("git-fetch-interval"); - expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity"); - expect(searchSettings("battery saver")[0]?.id).toBe("background-activity"); - expect(searchSettings("binary path")[0]?.id).toBe("providers"); - expect(searchSettings("Antigravity")[0]?.id).toBe("providers"); - expect(searchSettings("Google sign in")[0]?.id).toBe("providers"); - expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment"); - expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment"); - }); - - it("lists thread confirmations in panel order", () => { - expect(searchSettings("confirmation").map((item) => item.id)).toEqual([ - "unpin-confirmation", - "archive-confirmation", - "delete-confirmation", - ]); - }); - - it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])( - "finds usage-provider management by %s", - (query) => { - expect(searchSettings(query)[0]).toMatchObject({ - id: "usage-providers", - to: "/settings/providers", - }); - }, - ); - - it("returns no results for an empty query", () => { - expect(searchSettings(" ", ITEMS)).toEqual([]); - }); - it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); expect(searchSettings("hold to quit")).toEqual([]); @@ -143,34 +84,6 @@ describe("searchSettings", () => { expect(searchSettings("update track")).toEqual([]); }); - it("finds the About update rows", () => { - expect(searchSettings("check for updates").map((item) => item.id)).toContain("app-version"); - expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "update-track")).toMatchObject({ - title: "Update track", - to: "/settings/general", - releaseChannelOnly: true, - }); - }); - - it("hides macOS-only settings on other platforms", () => { - vi.stubGlobal("navigator", { platform: "Win32" }); - try { - expect(searchSettings("font smoothing")).toEqual([]); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("registers the WSL backend as a desktop-only setting", () => { - expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "wsl-backend")).toMatchObject({ - id: "wsl-backend", - title: "WSL backend", - to: "/settings/connections", - desktopOnly: true, - windowsOnly: true, - }); - }); - it("hides settings whose controls are unavailable", () => { const available = filterAvailableSettingsSearchItems({ hasCloudPublicConfig: false, @@ -204,83 +117,6 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); - it("offers Cursor Keychain settings only when a macOS provider environment is available", () => { - const availability = { - hasCloudPublicConfig: false, - hasEnvironment: true, - hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, - canManageLocalBackend: false, - isWslSettingsRowVisible: false, - hasThreadAutoSettlement: false, - }; - const itemIds = (macAvailable: boolean) => - filterAvailableSettingsSearchItems({ - ...availability, - hasMacProviderSettingsEnvironment: macAvailable, - }).map((item) => item.id); - expect(itemIds(false)).not.toContain("cursor-keychain-usage"); - expect(itemIds(true)).toContain("cursor-keychain-usage"); - }); - - it("keeps the local toggle searchable without offering hidden host publishing controls", () => { - const availability = { - hasCloudPublicConfig: true, - hasEnvironment: true, - hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, - canManageLocalBackend: false, - isWslSettingsRowVisible: false, - hasThreadAutoSettlement: false, - }; - const remoteOnly = filterAvailableSettingsSearchItems({ - ...availability, - localEnvironmentDisabled: true, - }).map((item) => item.id); - expect(remoteOnly).toContain("local-environment"); - expect(remoteOnly).not.toContain("t3-connect"); - expect(remoteOnly).not.toContain("publish-agent-activity"); - expect(remoteOnly).not.toContain("wsl-backend"); - const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id); - expect(browser).toContain("publish-agent-activity"); - }); - - it("offers webhook holding only while the managed tunnel is on, like its row", () => { - const availability = { - hasCloudPublicConfig: true, - hasEnvironment: true, - hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, - canManageLocalBackend: true, - isWslSettingsRowVisible: false, - hasThreadAutoSettlement: false, - }; - const itemIds = (managedTunnelActive: boolean) => - filterAvailableSettingsSearchItems({ ...availability, managedTunnelActive }).map( - (item) => item.id, - ); - expect(itemIds(false)).not.toContain("hold-webhooks-while-offline"); - expect(itemIds(true)).toContain("hold-webhooks-while-offline"); - }); - - it("shows automatic settlement settings when the server supports them", () => { - const available = filterAvailableSettingsSearchItems({ - hasCloudPublicConfig: false, - hasEnvironment: false, - hasProviderSettingsEnvironment: false, - hasMacProviderSettingsEnvironment: false, - canManageLocalBackend: false, - isWslSettingsRowVisible: false, - hasThreadAutoSettlement: true, - }); - - expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ - "auto-settle-inactive-threads", - "auto-settle-merged-threads", - "days-before-auto-settle", - ]); - }); - it("finds keybinding commands by label, command id, and default key", () => { expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle"); expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle"); @@ -291,14 +127,6 @@ describe("searchSettings", () => { }); }); - it("ranks keybinding commands after other settings", () => { - const ids = searchSettings("model").map((item) => item.id); - expect(ids[0]).toBe("default-model"); - expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan( - ids.indexOf("text-generation-model"), - ); - }); - it("sends commands without a default binding to the section", () => { expect(searchSettings("thread.stop")[0]).toMatchObject({ id: "keybinding-thread.stop", @@ -311,102 +139,6 @@ describe("searchSettings", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); }); - - it("serves anchor props to panels from the catalog", () => { - expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" }); - expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" }); - }); - - it("routes appearance settings to their current section", () => { - expect(searchSettings("theme")[0]).toMatchObject({ - id: "theme", - to: "/settings/appearance", - }); - expect(searchSettings("word wrap")[0]).toMatchObject({ - id: "word-wrap", - to: "/settings/appearance", - }); - expect(searchSettings("composer context")[0]).toMatchObject({ - id: "composer-context", - to: "/settings/appearance", - }); - expect(searchSettings("environment identification")[0]).toMatchObject({ - id: "environment-identification", - to: "/settings/appearance", - targetId: "appearance-interface", - }); - }); - - it("routes conditional window capture settings to the stable toggle row", () => { - const targets = [ - "capture accessibility data", - "capture shortcut", - "capture sound", - "capture flash", - "capture animations", - ].map((query) => { - const match = searchSettings(query)[0]; - return [match?.id, match?.targetId]; - }); - - expect(targets).toEqual([ - ["snap-shot-accessibility", "snap-shot-enabled"], - ["snap-shot-shortcut", "snap-shot-enabled"], - ["snap-shot-sound", "snap-shot-enabled"], - ["snap-shot-flash", "snap-shot-enabled"], - ["snap-shot-animations", "snap-shot-enabled"], - ]); - }); - - it("routes browser recording quality to integrations", () => { - const result = searchSettings("recording frame rate")[0]; - expect(result).toMatchObject({ - id: "browser-recording-frame-rate", - to: "/settings/integrations", - }); - expect(result).not.toHaveProperty("targetId"); - }); - - it("routes where links open to integrations", () => { - expect(searchSettings("open links in")[0]).toMatchObject({ - id: "browser-link-target", - to: "/settings/integrations", - }); - expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" }); - }); - - it("finds the default browser profile action in the profiles list", () => { - expect(searchSettings("default profile")[0]).toMatchObject({ - id: "browser-default-profile", - to: "/settings/integrations", - targetId: "browser-profiles", - }); - }); - - it.each([ - ["default model", "default-model", "/settings/general"], - ["new threads", "new-threads", "/settings/general"], - ["agent browser access", "agent-browser-access", "/settings/integrations"], - ["automatically pull", "automatic-pull", "/settings/source-control"], - ["actions", "project-actions", "/settings/projects"], - ["project overview", "project-overview", "/settings/projects"], - ])("routes %s to its owning category", (query, id, to) => { - expect(searchSettings(query)[0]).toMatchObject({ id, to }); - }); - - it("keeps environment settings discoverable without a primary environment", () => { - const available = filterAvailableSettingsSearchItems({ - hasCloudPublicConfig: false, - hasEnvironment: true, - hasProviderSettingsEnvironment: true, - hasMacProviderSettingsEnvironment: false, - canManageLocalBackend: false, - isWslSettingsRowVisible: false, - hasThreadAutoSettlement: true, - }); - expect(searchSettings("writing style", available)[0]?.id).toBe("source-control-writing-style"); - expect(searchSettings("auto-settle", available)).toHaveLength(3); - }); }); describe("settings search targets", () => { @@ -419,50 +151,6 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(target.scope, "project")).toBe(false); expect(isSettingsSearchScopeAvailable(target.scope, "environment")).toBe(true); }); - it.each([ - "auto-settle-inactive-threads", - "auto-settle-merged-threads", - "days-before-auto-settle", - ])("retains the capability requirement for %s", (targetId) => { - expect(getSettingsSearchTargetScope(targetId)).toMatchObject({ - scope: "project-defaults", - requiresThreadAutoSettlement: true, - }); - }); - - it("treats device-local rows as reachable from every selection", () => { - const setting = getSettingsSearchTargetScope("time-format")!; - expect(setting).toEqual({ title: "Time format", scope: null }); - expect(isSettingsSearchScopeAvailable(setting.scope, "project")).toBe(true); - expect(isSettingsSearchScopeAvailable(setting.scope, "all")).toBe(true); - expect(getSettingsSearchTargetScope("appearance")).toMatchObject({ scope: null }); - expect(getSettingsSearchTargetScope("missing-setting")).toBeNull(); - }); - - it.each(["all", "environment", "project", "checkout"] as const)( - "makes browser access editable at the %s scope", - (kind) => { - const setting = getSettingsSearchTargetScope("agent-browser-access")!; - expect(isSettingsSearchScopeAvailable(setting.scope, kind)).toBe(true); - expect(isSettingsSearchScopeAvailable(setting.scope, "unavailable")).toBe(false); - }, - ); - - it("lets project-scopable rows resolve at every server-backed scope", () => { - const model = getSettingsSearchTargetScope("text-generation-model")!; - expect(isSettingsSearchScopeAvailable(model.scope, "all")).toBe(true); - expect(isSettingsSearchScopeAvailable(model.scope, "environment")).toBe(true); - expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true); - }); - - it("reaches source control discovery and git fetch interval from the default scope", () => { - for (const id of ["source-control", "git-fetch-interval"]) { - const item = getSettingsSearchTargetScope(id)!; - expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true); - expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true); - expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(false); - } - }); it("keeps environment-wide settings out of project scopes", () => { const updates = getSettingsSearchTargetScope("provider-update-checks")!; @@ -524,15 +212,6 @@ describe("auto-settlement search availability", () => { ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false }); }); - it("allows a capable named environment regardless of other environments' capabilities", () => { - expect( - getThreadAutoSettlementSearchAvailability(environments, { - kind: "environment", - environmentIds: [capable.environmentId], - }).isTargetAvailable, - ).toBe(true); - }); - it.each([unsupported, offline, loading])( "does not render the target on $environmentId or silently fall back", (selected) => { @@ -545,15 +224,6 @@ describe("auto-settlement search availability", () => { }, ); - it("offers a capable environment instead of a dead target at an unavailable scope", () => { - expect( - getThreadAutoSettlementSearchAvailability(environments, { - kind: "unavailable", - environmentIds: [capable.environmentId], - }), - ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false }); - }); - it("ignores offline and unloaded targets when all connected targets support the setting", () => { const selected = [capable, offline, loading]; expect( diff --git a/apps/web/src/components/settings/themeEditorStore.test.ts b/apps/web/src/components/settings/themeEditorStore.test.ts deleted file mode 100644 index ec641b4ba8f6..000000000000 --- a/apps/web/src/components/settings/themeEditorStore.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { OCEAN_THEME_ID, T3_CHAT_THEME_ID } from "../../themePalette"; -import { toggleThemeEditorForTheme, useThemeEditorStore } from "./themeEditorStore"; - -afterEach(() => { - useThemeEditorStore.getState().closeThemeEditor(); -}); - -describe("toggleThemeEditorForTheme", () => { - it("opens a new editor seeded from the theme active for the current appearance", () => { - toggleThemeEditorForTheme({ - theme: T3_CHAT_THEME_ID, - themeHalves: { dark: OCEAN_THEME_ID }, - initialAppearance: "dark", - }); - - expect(useThemeEditorStore.getState().session).toMatchObject({ - editingThemeId: null, - seedThemeId: OCEAN_THEME_ID, - seedName: null, - initialAppearance: "dark", - }); - }); - - it("closes an open editor", () => { - toggleThemeEditorForTheme({ - theme: T3_CHAT_THEME_ID, - themeHalves: null, - initialAppearance: "light", - }); - toggleThemeEditorForTheme({ - theme: T3_CHAT_THEME_ID, - themeHalves: null, - initialAppearance: "light", - }); - - expect(useThemeEditorStore.getState().session).toBeNull(); - }); -}); diff --git a/apps/web/src/components/settings/themeInspector.test.ts b/apps/web/src/components/settings/themeInspector.test.ts deleted file mode 100644 index c802ce3d5fb3..000000000000 --- a/apps/web/src/components/settings/themeInspector.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - changedThemePaintKinds, - themeRoleFromUtilityClass, - type ThemePaintSnapshot, -} from "./themeInspector"; - -const WHITE_PAINT: ThemePaintSnapshot = { - background: "rgb(255, 255, 255)\nnone", - border: "rgb(255, 255, 255)\nnone", - foreground: "rgb(255, 255, 255)\nnone", -}; - -describe("changedThemePaintKinds", () => { - it("does not match an identical hardcoded color", () => { - expect(changedThemePaintKinds(WHITE_PAINT, { ...WHITE_PAINT })).toEqual([]); - }); - - it("reports only paint that changed with the probed token", () => { - expect( - changedThemePaintKinds(WHITE_PAINT, { - ...WHITE_PAINT, - background: "rgb(1, 254, 167)\nnone", - foreground: "rgb(1, 254, 167)\nnone", - }), - ).toEqual(["background", "foreground"]); - }); -}); - -describe("themeRoleFromUtilityClass", () => { - it("maps semantic utilities to their actual palette role", () => { - expect(themeRoleFromUtilityClass("text-foreground", "foreground")).toBe("text"); - expect(themeRoleFromUtilityClass("text-muted-foreground", "foreground")).toBe( - "mutedForeground", - ); - expect(themeRoleFromUtilityClass("bg-primary/90", "background")).toBe("messageAction"); - expect(themeRoleFromUtilityClass("ring-ring", "border")).toBe("focus"); - }); - - it("does not infer hardcoded or potentially inactive colors", () => { - expect(themeRoleFromUtilityClass("bg-white", "background")).toBeNull(); - expect(themeRoleFromUtilityClass("hover:bg-accent", "background")).toBeNull(); - }); -}); diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 4479bc54c9c5..4c6c7a9e7151 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -301,7 +301,7 @@ function getThemePaintSnapshot( }; } -export function changedThemePaintKinds( +function changedThemePaintKinds( before: ThemePaintSnapshot, after: ThemePaintSnapshot, ): ReadonlyArray { @@ -358,10 +358,7 @@ function themeInspectorCandidates(): ReadonlyArray { ); } -export function themeRoleFromUtilityClass( - className: string, - kind: ThemePaintKind, -): ThemeColorRole | null { +function themeRoleFromUtilityClass(className: string, kind: ThemePaintKind): ThemeColorRole | null { if (className.includes(":")) return null; for (const prefix of THEME_UTILITY_PREFIXES[kind]) { if (!className.startsWith(prefix)) continue; diff --git a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx index cc354b1d2119..1804ef721048 100644 --- a/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx +++ b/apps/web/src/components/settings/useProjectScriptSettings.browser.test.tsx @@ -1,3 +1,4 @@ +import { act, render } from "../../../test/render"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -6,9 +7,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { NewProjectScriptInput } from "../projectScriptEditor"; @@ -87,35 +86,35 @@ function Probe(props: { return null; } -let root: Root; -let container: HTMLDivElement; - async function renderSettings( keybindings: ResolvedKeybindingsConfig, scope: "project" | "environment" = "project", ) { let settings: ProjectScriptSettings | undefined; - await act(async () => - root.render( - (settings = next)} - />, - ), + keybindings, + ...(scope === "project" + ? { project: { id: ProjectId.make("project"), scripts: [lint] } } + : {}), + }, + ]} + onResult={(next) => (settings = next)} + />, ); - return settings!; + return { + ...screen, + get settings() { + return settings!; + }, + }; } async function submitInAct( @@ -128,29 +127,19 @@ async function submitInAct( }); return result!; } - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { vi.clearAllMocks(); - for (const command of Object.values(commands)) { + for (const command of Object.values(commands)) command.mockResolvedValue(AsyncResult.success(undefined)); - } - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); +} describe("useProjectScriptSettings", () => { it.each(["project", "environment"] as const)( "preserves the shortcut for %s action name and command edits", async (scope) => { - const { submit } = await renderSettings(lintBinding, scope); + setup(); + await using screen = await renderSettings(lintBinding, scope); + const { submit } = screen.settings; const changes = { name: "Lint and fix", command: "vp lint --fix" }; const result = await submitInAct(submit, changes); @@ -170,64 +159,4 @@ describe("useProjectScriptSettings", () => { expect(commands.upsertKeybinding).not.toHaveBeenCalled(); }, ); - - it("saves a recorded action shortcut", async () => { - const { submit } = await renderSettings([]); - - const result = await submitInAct(submit, { keybinding: "mod+shift+k" }); - - expect(result._tag).toBe("Success"); - expect(commands.upsertKeybinding).toHaveBeenCalledWith({ - environmentId, - input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }), - }); - }); - - it("removes a cleared action shortcut", async () => { - const { submit } = await renderSettings(lintBinding); - - const result = await submitInAct(submit, { keybinding: null }); - - expect(result._tag).toBe("Success"); - expect(commands.removeKeybinding).toHaveBeenCalledWith({ - environmentId, - input: expect.objectContaining({ key: "mod+shift+k", command: "script.lint.run" }), - }); - expect(commands.upsertKeybinding).not.toHaveBeenCalled(); - }); - - it("replaces an explicitly changed action shortcut", async () => { - const { submit } = await renderSettings(lintBinding); - - const result = await submitInAct(submit, { keybinding: "mod+shift+l" }); - - expect(result._tag).toBe("Success"); - expect(commands.upsertKeybinding).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { - command: "script.lint.run", - key: "mod+shift+l", - replace: { command: "script.lint.run", key: "mod+shift+k" }, - }, - }); - expect(commands.removeKeybinding).not.toHaveBeenCalled(); - }); - - it("removes the shortcut when its action is deleted", async () => { - const { persist } = await renderSettings(lintBinding); - - await act(async () => { - expect((await persist(() => []))._tag).toBe("Success"); - }); - - expect(commands.updateSettings).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { patch: { projectSettingsOverrides: { project: { defaultProjectScripts: [] } } } }, - }); - expect(commands.removeKeybinding).toHaveBeenCalledExactlyOnceWith({ - environmentId, - input: { key: "mod+shift+k", command: "script.lint.run" }, - }); - expect(commands.upsertKeybinding).not.toHaveBeenCalled(); - }); }); diff --git a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx index c81720038dd4..da79179f2026 100644 --- a/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx +++ b/apps/web/src/components/settings/useSnapShotShortcutRecorder.browser.test.tsx @@ -1,8 +1,7 @@ +import { act, render } from "../../../test/render"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; -import { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; import { userEvent } from "vite-plus/test/browser"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { expect, it, vi } from "vite-plus/test"; const suppress = vi.hoisted(() => vi.fn<(enabled: boolean) => Promise>()); vi.mock(import("../../lib/desktopSnapShot"), async (original) => ({ @@ -22,9 +21,6 @@ function deferred() { const recorded = vi.fn(); const error = vi.fn(); -let root: Root; -let container: HTMLDivElement; -let recording = false; function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string }) { const recorder = useSnapShotShortcutRecorder({ @@ -34,142 +30,43 @@ function Recorder(props: { allowModifierPairs?: boolean; shortcutLabel?: string onRecord: recorded, onError: error, }); - useEffect(() => { - recording = recorder.recording; - }); return recorder.input; } -async function render(props: Parameters[0] = {}) { - await act(async () => root.render()); -} -const button = () => container.querySelector("[data-keybinding-capture]")!; -async function start(props: Parameters[0] = {}) { - await render(props); - await act(async () => userEvent.click(button())); - expect(recording).toBe(true); -} -const press = (keys: string) => act(async () => userEvent.keyboard(keys)); -function setPlatform(platform: string) { - Object.defineProperty(navigator, "platform", { configurable: true, get: () => platform }); -} - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); +function setup() { suppress.mockReset().mockResolvedValue(undefined); recorded.mockReset(); error.mockReset(); - setPlatform("Linux x86_64"); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - Reflect.deleteProperty(navigator, "platform"); - vi.unstubAllGlobals(); -}); - -it("suppresses capture while recording and uses the physical key for shifted digits", async () => { - await start(); - expect(suppress).toHaveBeenCalledExactlyOnceWith(true); - expect(button().textContent).toBe("Press shortcut…"); - await press("{Control>}{Shift>}2{/Shift}{/Control}"); - expect(recorded).toHaveBeenCalledExactlyOnceWith({ - key: "2", - modKey: true, - ctrlKey: false, - metaKey: false, - altKey: false, - shiftKey: true, - }); - expect(suppress).toHaveBeenLastCalledWith(false); - expect(recording).toBe(false); -}); - -it("lets users replace an unrecognized desktop label without displaying guessed keys", async () => { - const shortcutLabel = "Use the shortcut assigned in desktop settings"; - await render({ shortcutLabel }); - expect(button().textContent).toBe("Change shortcut"); - expect(button().getAttribute("aria-label")).toBe("Change snapshot shortcut"); - await act(async () => userEvent.click(button())); - expect(button().textContent).toBe("Press shortcut…"); - await press("{Control>}{Shift>}2{/Shift}{/Control}"); - expect(recorded).toHaveBeenCalledWith( - expect.objectContaining({ key: "2", modKey: true, shiftKey: true }), - ); - expect(suppress).toHaveBeenLastCalledWith(false); -}); - -it("records macOS Command without treating it as Control", async () => { - setPlatform("MacIntel"); - await start({ allowModifierPairs: true }); - await press("{Meta>}k{/Meta}"); - expect(recorded).toHaveBeenCalledWith(expect.objectContaining({ key: "k", modKey: true })); -}); - -it.each(["Escape", "blur", "unmount"])("cancels on %s without selecting keys", async (cancel) => { - await start(); - if (cancel === "Escape") { - const outside = vi.fn(); - document.addEventListener("keydown", outside); - await press("{Escape}"); - document.removeEventListener("keydown", outside); - expect(outside).not.toHaveBeenCalled(); - } else if (cancel === "blur") await act(async () => button().blur()); - else await act(async () => root.render(null)); - expect(suppress).toHaveBeenLastCalledWith(false); - expect(recorded).not.toHaveBeenCalled(); - if (cancel !== "unmount") expect(recording).toBe(false); -}); +} +async function renderRecorder(props: Parameters[0] = {}) { + const screen = await render(); + const button = () => + screen.container.querySelector("[data-keybinding-capture]")!; + // aria-pressed mirrors the hook's recording state, which flips once suppression resolves. + const expectRecording = (recording: boolean) => + expect.element(screen.getByRole("button")).toHaveAttribute("aria-pressed", String(recording)); + return { + ...screen, + button, + expectRecording, + async start() { + await userEvent.click(button()); + await expectRecording(true); + }, + }; +} it("ignores late suppression acknowledgements after cancellation", async () => { + setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); const pending = deferred(); suppress.mockImplementation((enabled) => (enabled ? pending.promise : Promise.resolve())); - await render(); - await act(async () => userEvent.click(button())); + await using screen = await renderRecorder(); + const { button } = screen; + await userEvent.click(button()); await act(async () => button().blur()); await act(async () => pending.resolve()); - expect(recording).toBe(false); + await screen.expectRecording(false); expect(suppress).toHaveBeenLastCalledWith(false); expect(recorded).not.toHaveBeenCalled(); }); - -it("reports suppression failures instead of arming the recorder", async () => { - suppress.mockRejectedValue(new Error("Couldn't pause capture")); - await render(); - await act(async () => userEvent.click(button())); - expect(recording).toBe(false); - expect(error).toHaveBeenCalledWith("Couldn't pause capture"); -}); - -it("does not record Tab, unmodified keys, or modifier keys on their own", async () => { - await start(); - await press("y"); - await press("{Control>}{/Control}"); - expect(recorded).not.toHaveBeenCalled(); - expect(recording).toBe(true); - await press("{Tab}"); - expect(document.activeElement).not.toBe(button()); -}); - -it("rejects modifier pairs on Wayland but accepts the next key chord", async () => { - await start(); - await press("{ShiftLeft>}{ShiftRight>}{/ShiftRight}{/ShiftLeft}"); - expect(recorded).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith(expect.stringContaining("Add a letter, number")); - expect(recording).toBe(true); - await press("{Control>}{Alt>}y{/Alt}{/Control}"); - expect(recorded).toHaveBeenCalledOnce(); -}); - -it("preserves native modifier-pair recording and tracks released keys", async () => { - await start({ allowModifierPairs: true }); - await press("{ShiftLeft}"); - await press("{ShiftRight>}"); - expect(recorded).not.toHaveBeenCalled(); - await press("{ShiftLeft>}"); - expect(recorded).toHaveBeenCalledExactlyOnceWith({ kind: "both-shift-keys" }); - await press("{/ShiftLeft}{/ShiftRight}"); -}); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 0b91e9907bab..f38162d9fb66 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -41,7 +41,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { - const { isMobile } = useSidebar(); const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const backdropVariant = resolveSidebarStageBackdropVariant( @@ -61,17 +60,15 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ )} > {backdropVariant ? : null} - {isMobile ? ( -
- -
- ) : null} +
+ +
- {!isMobile ? : null} + {pillLabel ? (
@@ -196,7 +193,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { void; + onDiscard: (draftId: DraftId) => void; + onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; +}) { + const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; + const promptPreview = + replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) + .trim() + .split("\n", 1)[0] ?? ""; + // images mirrors persistedAttachments once rehydration finishes; before + // that only the persisted list is populated, hence max not sum. + const attachmentCount = + Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.files.length + + composer.terminalContexts.length + + composer.previewAnnotations.length + + composer.reviewComments.length; + const preview = + promptPreview.length > 0 + ? promptPreview + : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + const accessibility = resolveSidebarRowAccessibility({ + title: preview, + statusLabel: "Unsent draft", + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + const handleActivate = () => onNavigate(draftId); + const handleKeyDown = (event: ReactKeyboardEvent) => { + // Keys targeting the nested discard button belong to the button: + // preventDefault here would swallow Space's synthesized click and + // navigate instead of discarding. + if ((event.target as HTMLElement).closest("button")) return; + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(draftId, { x: rect.left, y: rect.bottom }); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onNavigate(draftId); + } + }; + const handleContextMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(draftId, { x: event.clientX, y: event.clientY }); + }; + const handleDiscard = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDiscard(draftId); + }; + return ( +
  • +
    + {preview} +
    +
    + + {props.project ? ( + + ) : null} + + {props.projectDisplayName} + + + + + + + } + /> + Discard draft + + +
    +
    + {preview} +
    +
    +
    +
  • + ); +}); + +interface SidebarDraftRowData { + draftId: DraftId; + session: DraftSessionState; + composer: ComposerThreadDraftState; +} + +export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectByKey: ReadonlyMap; + projectDisplayNameByKey: ReadonlyMap; + scopedProjectKeys: ReadonlySet | null; + routeDraftId: string | null; + onNavigateToDraft: (draftId: DraftId) => void; + onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; +}) { + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const [frozenActive, setFrozenActive] = useState<{ + routeDraftId: string | null; + row: SidebarDraftRowData | null; + }>({ routeDraftId: null, row: null }); + if (frozenActive.routeDraftId !== props.routeDraftId) { + let row: SidebarDraftRowData | null = null; + if (props.routeDraftId !== null) { + const draftId = DraftId.make(props.routeDraftId); + const session = draftThreadsByThreadKey[draftId]; + const composer = draftsByThreadKey[draftId]; + row = + session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) + ? { draftId, session, composer } + : null; + } + setFrozenActive({ routeDraftId: props.routeDraftId, row }); + } + const drafts = (() => { + const rows: SidebarDraftRowData[] = []; + for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if ( + props.scopedProjectKeys !== null && + !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) + ) { + continue; + } + if (draftKey === props.routeDraftId) { + if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { + rows.push(frozenActive.row); + } + continue; + } + const composer = draftsByThreadKey[draftKey]; + if (!composer || !composerDraftHasUserContent(composer)) { + continue; + } + rows.push({ draftId: DraftId.make(draftKey), session, composer }); + } + rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); + return rows; + })(); + if (drafts.length === 0) { + return null; + } + return ( + <> + {drafts.map(({ composer, draftId, session }) => { + const projectKey = `${session.environmentId}:${session.projectId}`; + return ( + + ); + })} +
  • + + ); +}); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx new file mode 100644 index 000000000000..0bfee0f6bfa8 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -0,0 +1,135 @@ +import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { type ReactNode } from "react"; +import { cn } from "~/lib/utils"; +import { + animateSidebarLayoutChanges, + sidebarMarkerId, + type SidebarListMarker, +} from "../Sidebar.logic"; +import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; + +function SortableSidebarMarker(props: { + marker: SidebarListMarker; + className?: string; + children?: ReactNode; + "data-testid"?: string; +}) { + const { setNodeRef, transform, transition } = useSortable({ + id: sidebarMarkerId(props.marker), + disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + return ( +
  • + {props.children} +
  • + ); +} + +export function SidebarSectionPlaceholder(props: { + marker: "active-placeholder" | "settled-placeholder"; + label: string; + showHint: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.showHint ? ( +
    + {props.label} +
    + ) : null} +
    + ); +} + +export function SidebarDragBoundary(props: { + marker: "pinned-header" | "pinned-divider"; + label: string; + visible: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.visible ? ( +
    + + {props.label} + + +
    + ) : null} +
    + ); +} + +export function SidebarSectionHeader(props: { + marker: "working-header" | "snoozed-header" | "settled-header"; + label: string; + className?: string; + dragging?: boolean; + isDropTarget?: boolean; + toggle: { expanded: boolean; onToggle: () => void }; +}) { + const shelf = + props.marker === "working-header" + ? "working" + : props.marker === "snoozed-header" + ? "snoozed" + : "settled"; + const snoozed = shelf === "snoozed"; + return ( + + + {props.label} + + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx new file mode 100644 index 000000000000..a73ca0725a92 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -0,0 +1,1668 @@ +import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; +import { discardComposerDraft } from "../../lib/discardComposerDraft"; +import { requestCustomSnooze } from "../CustomSnoozeDialog"; +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + canSnooze, + hasQueuedTurnStart, + QUEUED_TURN_START_GRACE_MS, +} from "@t3tools/client-runtime/state/thread-settled"; +import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { type EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { + formatWorkingDurationLabel, + resolveThreadProviderStack, +} from "@t3tools/client-runtime/state/models"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + AuthOrchestrationOperateScope, + type EnvironmentMachineKind, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { + AlarmClockIcon, + AlarmClockOffIcon, + ArrowRightLeftIcon, + CheckIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleDotDashedIcon, + ClockIcon, + GitBranchIcon, + MessageCircleQuestionIcon, + PinIcon, + PinOffIcon, + ShieldQuestionIcon, + SquarePenIcon, + TerminalIcon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { + memo, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, +} from "react"; + +import { useRightPanelStore } from "../../rightPanelStore"; +import { useOpenPrLink } from "../../lib/openPullRequestLink"; +import { useUiStateStore } from "../../uiStateStore"; +import { useThreadSelectionStore } from "../../threadSelectionStore"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { useNowSecond } from "../../hooks/useNowSecond"; +import { vcsEnvironment } from "../../state/vcs"; +import { useEnvironmentQuery } from "../../state/query"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; +import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; +import type { SidebarThreadSummary } from "../../types"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { cn } from "~/lib/utils"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { + animateSidebarLayoutChanges, + hasUnseenCompletion, + resolveSidebarRowAccessibility, + type SidebarDropVerb, + resolveSidebarThreadStatus, + resolveThreadLastVisitedAt, + shouldRecedeSidebarThread, + resolveWorkingStartedAt, + useRetainedValue, + useSidebarRowSubscriptionLease, +} from "../Sidebar.logic"; +import { resolveLocalCheckoutBranchMismatch } from "../BranchToolbar.logic"; +import { + ThreadPullRequestBadgeControl, + ThreadPullRequestsMiniList, + ThreadWorktreeIndicator, + prStatusIndicator, + resolveThreadPullRequestBadge, + terminalStatusFromRunningIds, + type TerminalStatusIndicator, + useLinkedThreadPullRequest, + ThreadSearchPullRequestNumber, +} from "../ThreadStatusIndicators"; +import { resolveSnoozePresets, type SnoozePreset } from "../Sidebar.snooze"; +import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; +import { ThreadSearchMatchExcerpt } from "../ThreadSearchMatch"; +import { makeWorkspaceFileDropHandlers } from "../chat/workspaceFileDrop"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getTriggerDisplayModelLabel } from "../chat/providerIconUtils"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; +import { useThreadRunningTerminalIds } from "../../state/terminalSessions"; +import { InlineButton } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import { useThreadHasUnsentDraft } from "../../composerDraftStore"; + +// The fields thread rows show. Provider status pushes refresh `checkedAt`, +// status and auth on every provider; leaving those out keeps each +// environment's map, and every memoized row, stable across them. +export type SidebarProviderEntry = Pick< + ProviderInstanceEntry, + | "instanceId" + | "driverKind" + | "enabled" + | "displayName" + | "accentColor" + | "acpRegistryAgentId" + | "acpRegistryIconUrl" + | "models" +>; +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; +} + +// Subscribes to the minute tick itself, so only the label re-renders each +// minute. The minute is passed as the clock so it is the formatter's input. +function RelativeTimeLabel(props: { timestamp: string | null }) { + const nowMinute = useNowMinute(); + return props.timestamp === null + ? null + : compactSidebarTimeLabel( + formatRelativeTimeLabel(props.timestamp, Date.parse(`${nowMinute}:00.000Z`)), + ); +} + +function threadTimeTimestamp(thread: SidebarThreadSummary): string { + return thread.latestUserMessageAt ?? thread.updatedAt; +} + +function JumpHintBadge(props: { label: string }) { + return ( + + {props.label} + + ); +} + +function WorkingDuration(props: { startedAt: string | null }) { + const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; + if (Number.isNaN(startedMs)) return null; + return ; +} + +// Self-ticking so only this span re-renders each second, not the whole row; +// the shared tick lands every working row's update in the same frame. +function WorkingDurationLabel(props: { startedMs: number }) { + const nowMs = useNowSecond(); + return ( + {formatWorkingDurationLabel(nowMs - props.startedMs)} + ); +} + +function terminalProcessLabel(count: number): string { + return `${count} terminal ${count === 1 ? "process" : "processes"} running`; +} + +function SidebarProviderStack(props: { + thread: SidebarThreadSummary; + providerEntryByInstanceId: ReadonlyMap; +}) { + const stack = resolveThreadProviderStack(props.thread); + const currentInstanceId = stack[stack.length - 1]!; + const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; + if (currentEntry === null) return null; + const showInstanceBadge = shouldShowInstanceBadge( + currentEntry, + props.providerEntryByInstanceId.values(), + ); + const current = ( + + ); + if (stack.length === 1) { + return {current}; + } + return ( + + {stack.slice(0, -1).map((instanceId) => { + const entry = props.providerEntryByInstanceId.get(instanceId); + if (entry === undefined) return null; + return ( + + ); + })} + {current} + + ); +} + +function SidebarThreadTooltip({ + thread, + project, + projectDisplayName, + environmentLabel, + environmentMachine, + providerEntry, + providerEntryByInstanceId, + showInstanceBadge, + modelInstanceId, + modelLabel, + branchMismatch, + terminalStatus, + terminalProcessCount, +}: { + thread: SidebarThreadSummary; + project: ProjectFaviconProject | null; + projectDisplayName: string | null; + environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; + providerEntry: SidebarProviderEntry | null; + providerEntryByInstanceId: ReadonlyMap; + showInstanceBadge: boolean; + modelInstanceId: string; + modelLabel: string; + branchMismatch: { + threadBranch: string; + currentBranch: string; + } | null; + terminalStatus: TerminalStatusIndicator | null; + terminalProcessCount: number; +}) { + const driverKind = providerEntry?.driverKind ?? null; + const previousProviderNames = thread.providerInstanceHistory + .filter((instanceId) => instanceId !== modelInstanceId) + .map((instanceId) => providerEntryByInstanceId.get(instanceId)?.displayName ?? instanceId); + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + return ( + + 0 ? ( +
    + +
    + ) : null + } + > + {projectDisplayName ? ( +
    + {project ? : null} +
    {projectDisplayName}
    +
    + ) : null} + {environmentLabel ? ( +
    + +
    {environmentLabel}
    +
    + ) : null} + {thread.branch ? ( +
    + + +
    + ) : null} + {branchMismatch ? ( +
    + +
    + You're currently checked out on another branch. +
    +
    + ) : null} + {driverKind ? ( +
    + +
    + {showInstanceBadge && providerEntry + ? `${modelLabel} · ${providerEntry.displayName}` + : modelLabel} +
    +
    + ) : null} + {previousProviderNames.length > 0 ? ( +
    + +
    + Handed off from {previousProviderNames.join(", ")} +
    +
    + ) : null} + {terminalStatus ? ( +
    + +
    + {terminalProcessLabel(terminalProcessCount)} +
    +
    + ) : null} + {thread.runtime?.lastError ? ( +
    + +
    + {thread.runtime.lastErrorClass === "usage_limit" + ? "Usage limit reached" + : "Error occurred"} +
    +
    + ) : null} +
    +
    + ); +} + +function SnoozeMenuButton(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSnooze: (preset: Pick) => void; + timestampFormat: TimestampFormat; +}) { + const { open, onOpenChange, onSnooze, timestampFormat } = props; + const presets = open ? resolveSnoozePresets(new Date(), timestampFormat) : []; + return ( + + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + + + {presets.map((preset) => ( + { + event.stopPropagation(); + onSnooze(preset); + }} + > + {preset.label} + {preset.whenLabel} + + ))} + + { + event.stopPropagation(); + const choice = await requestCustomSnooze(); + if (choice) onSnooze(choice); + }} + > + Custom… + + + + ); +} + +type SortableThreadRowBag = Pick< + ReturnType, + "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" +>; + +export const draftSurfaceClassName = "bg-warning/4 hover:bg-warning/8"; +export const draftPenClassName = "size-3 shrink-0 text-warning-foreground"; + +// useSortable memoizes on its `disabled` object, so a fresh one per render +// re-renders every row and marker on each sidebar update. +export const SIDEBAR_SORTABLE_DRAG_DISABLED = { draggable: true } as const; +const SIDEBAR_SORTABLE_DRAG_ENABLED = { draggable: false } as const; + +// Takes the row's props rather than a render function: a closure from the +// sidebar body is new on every sidebar render, which rebuilt the bag and +// re-rendered every memoized row with it. +export function SortableThreadRow( + props: Omit & { + sortableId: string; + sortableDisabled: boolean; + contextDrag: boolean; + }, +) { + const { sortableId, sortableDisabled, contextDrag, ...rowProps } = props; + const canOperateThread = useEnvironmentScope( + rowProps.thread.environmentId, + AuthOrchestrationOperateScope, + ); + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: sortableId, + disabled: + sortableDisabled || !canOperateThread + ? SIDEBAR_SORTABLE_DRAG_DISABLED + : SIDEBAR_SORTABLE_DRAG_ENABLED, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + // dnd-kit memoizes each field but not the bag, so the memoized row would + // rerender on every shell update without this. + const bag = { + listeners, + setNodeRef, + transform: contextDrag ? null : transform, + transition: contextDrag && isDragging ? "transform 150ms ease-out" : transition, + isDragging: isDragging && !contextDrag, + }; + // Keyed by variant so a card/slim switch remounts the row. + return ; +} +export type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; +const dropVerbBadge: Record = { + pin: ( + <> + + Pin + + ), + unpin: ( + <> + + Unpin + + ), + settle: ( + <> + + Settle + + ), + unsettle: ( + <> + + Un-settle + + ), + wake: ( + <> + + Wake + + ), +}; + +type SidebarThreadRowProps = { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + variantAction: SidebarSweepAction; + settlementSupported: boolean; + snoozeSupported: boolean; + pinningSupported: boolean; + isPinned: boolean; + sortable?: SortableThreadRowBag | undefined; + dropVerb: SidebarDropVerb | null; + dragOverPinned: boolean; + sweepAction: SidebarSweepAction | null; + snoozeWakeLabelText: string | null; + wokeAt: string | null; + isActive: boolean; + // Unlike isActive, never true for the ancestor standing in for an open + // subagent: drafts and PR clicks care about which composer is on screen. + isRouteThread: boolean; + openPullRequestsInRightPanel: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; + scratchMachineLabel: string | null; + project: EnvironmentProject | null; + projectDisplayName: string | null; + providerEntryByInstanceId: ReadonlyMap; + timestampFormat: TimestampFormat; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onActionSweepStart: ( + threadRef: ScopedThreadRef, + action: SidebarSweepAction, + event: PointerEvent, + ) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; + onUnsnooze: (threadRef: ScopedThreadRef) => void; + onUnpin: (threadRef: ScopedThreadRef) => void; + onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; + onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; +}; + +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + isRenaming, + onCancelRename, + onCommitRename, + onContextMenu, + onAcknowledgeWoke, + onFileDropThreads, + onRenameTitleChange, + onSettle, + onActionSweepStart, + onSnooze, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + onUnsnooze, + onUnpin, + openPullRequestsInRightPanel, + renamingTitle, + thread, + variant, + variantAction, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); + useEffect(() => { + if (!canOperateThread && isRenaming) onCancelRename(); + }, [canOperateThread, isRenaming, onCancelRename]); + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); + const isRegeneratingTitle = thread.titleRegeneration != null; + const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const terminalProcessCount = runningTerminalIds.length; + const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isRouteThread; + const handleDiscardDraftClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + discardComposerDraft(threadRef); + }; + + const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; + const linkedPullRequestStatus = useLinkedThreadPullRequest( + thread.environmentId, + thread.linkedPullRequest, + leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, + ); + const gitStatus = useEnvironmentQuery( + leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd, includeRemote: false }, + }) + : null, + ); + const visibleGitStatus = useRetainedValue( + JSON.stringify([thread.environmentId, gitCwd]), + gitStatus.data, + ); + const pr = linkedPullRequestStatus?.pr ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; + + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarThreadStatus(thread); + const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); + const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); + const isWoke = + wokeAtDate !== null && + (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && + thread.settledOverride !== "settled"; + const shouldRecede = shouldRecedeSidebarThread({ + status, + isUnread, + isWoke, + isActive: props.isActive, + isSelected, + }); + const topStatus = + status === "working" + ? { + label: thread.goal?.status === "active" ? "Goal" : "Working", + icon: "working" as const, + className: "text-info", + } + : status === "waiting" + ? { + label: "Waiting", + icon: "waiting" as const, + className: "text-muted-foreground", + } + : status === "approval" + ? { + label: "Approval", + icon: "approval" as const, + className: "text-warning-foreground", + } + : status === "input" + ? { + label: "Input", + icon: "input" as const, + className: "text-indigo-600 dark:text-indigo-300", + } + : status === "limited" + ? { + label: "Limited", + icon: "failed" as const, + className: "text-warning", + } + : status === "failed" + ? { + label: "Failed", + icon: "failed" as const, + className: "text-error", + } + : isWoke + ? { + label: "Woke", + icon: "woke" as const, + className: "text-warning", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "text-success", + } + : null; + const isWokeStatus = topStatus?.icon === "woke"; + + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: visibleGitStatus?.refName ?? null, + }); + const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); + + const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + + const isRemote = thread.environmentId !== props.currentEnvironmentId; + const showsScratchMachine = !thread.branch && props.scratchMachineLabel !== null; + + const detailsTooltip = ( + + ); + + const handleClick = (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }; + const handleAcknowledgeWokeClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (props.wokeAt === null) return; + onAcknowledgeWoke(threadRef, props.wokeAt); + }; + const handleContextMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }; + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }; + const handleDoubleClick = (event: ReactMouseEvent) => { + if ( + !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) || + isRenaming || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }; + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = onFileDropThreads + ? makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + addFolders: () => {}, + }) + : null; + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }; + const handleRenameBlur = () => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }; + const handleSettleClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }; + const handleActionPointerDown = (event: ReactPointerEvent) => { + if (!event.isPrimary || event.button !== 0) return; + event.stopPropagation(); + onActionSweepStart(threadRef, variantAction, event.nativeEvent); + }; + const handleUnsettleClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }; + const handleUnsnoozeClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsnooze(threadRef); + }; + const handleUnpinClick = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnpin(threadRef); + }; + const handleSnoozePreset = (preset: Pick) => { + onSnooze(threadRef, preset); + }; + const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); + // The React Compiler caches render results on their inputs, so a clock read + // here would stay frozen until `thread` changes and a queued-turn gate would + // never reopen. The gate's clock is state, reset with the thread. + const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ + thread, + now: new Date().toISOString(), + })); + if (snoozeGateClock.thread !== thread) { + setSnoozeGateClock({ thread, now: new Date().toISOString() }); + } + const snoozeGateNow = snoozeGateClock.now; + const snoozeGateExpiryMs = hasQueuedTurnStart(thread, { now: snoozeGateNow }) + ? Date.parse(thread.latestUserMessageAt ?? "") + QUEUED_TURN_START_GRACE_MS + : Number.NaN; + useEffect(() => { + // A gate still closed past its expiry is held by runtime status, not time, + // so no clock tick can open it. + if (!Number.isFinite(snoozeGateExpiryMs) || snoozeGateExpiryMs <= Date.parse(snoozeGateNow)) { + return; + } + const id = window.setTimeout( + () => setSnoozeGateClock({ thread, now: new Date().toISOString() }), + Math.min(Math.max(0, snoozeGateExpiryMs - Date.now()) + 50, 2_147_483_647), + ); + return () => window.clearTimeout(id); + }, [snoozeGateExpiryMs, snoozeGateNow, thread]); + const showSnoozeButton = + canOperateThread && props.snoozeSupported && canSnooze(thread, { now: snoozeGateNow }); + // Closing here, not just masking, keeps the menu from reopening when the button returns. + if (snoozeMenuOpenRaw && !showSnoozeButton) setSnoozeMenuOpen(false); + const snoozeMenuOpen = snoozeMenuOpenRaw && showSnoozeButton; + const handlePrClick = (event: ReactMouseEvent, targetUrl?: string) => { + const url = targetUrl ?? pr?.url ?? currentLinkedPr?.url; + if (!url) return; + const showInRightPanel = targetUrl !== undefined || openPullRequestsInRightPanel; + const openedInRightPanel = openPrLink(event, url, showInRightPanel ? threadRef : undefined); + if (openedInRightPanel && showInRightPanel && !props.isRouteThread) { + onThreadActivate(threadRef); + } + }; + + const rowSurfaceClassName = cn( + "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", + props.isActive + ? "bg-sidebar-row-active text-sidebar-foreground" + : isSelected || props.sweepAction !== null + ? "bg-sidebar-row-selected text-sidebar-foreground" + : hasUnsentDraft + ? cn(draftSurfaceClassName, "text-sidebar-foreground") + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + shouldRecede && + status === "working" && + "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", + isFileDragOver && "ring-1 ring-inset ring-primary/70", + isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", + props.sortable?.isDragging && + "bg-sidebar bg-linear-to-b from-sidebar-row-active to-sidebar-row-active text-sidebar-foreground opacity-100 shadow-lg", + ); + const sortable = props.sortable; + const sortableRootProps = sortable + ? { + ref: sortable.setNodeRef, + style: { + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + // A zero-height boundary also makes dnd-kit scale the source to + // zero. Only projected peers use scaleY as a visibility sentinel. + visibility: + !sortable.isDragging && sortable.transform?.scaleY === 0 + ? ("hidden" as const) + : undefined, + }, + ...sortable.listeners, + } + : {}; + const destinationVerb = sortable?.isDragging + ? props.dropVerb + : props.sweepAction === "unsnooze" + ? "wake" + : props.sweepAction; + const dragDestination = + destinationVerb !== null ? ( + + {dropVerbBadge[destinationVerb]} + + ) : null; + + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: topStatus?.label ?? null, + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + + const title = + isRenaming && canOperateThread ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ) : ( + + {thread.title} + + ); + const accessibleTitle = isRenaming ? null : {thread.title}; + + const prBadgeShape = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(thread.pullRequests) + : null; + const handlePrListClick = () => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + if (!props.isRouteThread) onThreadActivate(threadRef); + }; + const prBadge = + prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( + } + badge={prBadgeShape} + pullRequests={thread.pullRequests} + number={pr?.number ?? currentLinkedPr?.number} + url={pr?.url ?? currentLinkedPr?.url} + status={prStatus} + onOpenList={handlePrListClick} + onOpenPullRequest={handlePrClick} + /> + ) : null; + const terminalStatusIcon = ( + + ); + const draftIndicator = hasUnsentDraft ? ( + + + } + > + + + Unsent draft + + ) : null; + const showPin = + props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); + const pinIndicator = showPin ? ( + props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( + + + } + > + + + + Unpin thread + + ) : ( + + ) + ) : null; + + if (variant === "slim") { + return ( +
  • + + + } + > + {accessibleTitle} + + {props.project ? : null} + + {draftIndicator} + {title} + {pinIndicator} + {terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {prBadge} + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + + + } + > + + + Un-settle thread + + ) : ( + + )} + + )} + {props.sweepAction !== null ? dragDestination : null} + {props.jumpLabel ? : null} + + {detailsTooltip} + +
  • + ); + } + + const diff = latestRunDiff(thread); + + return ( +
  • + + + } + > + {accessibleTitle} +
    +
    + {draftIndicator} + {props.project ? ( + + ) : null} + {/* Left of the status slot, the pin would slide when hover swaps the + label for the wider row controls. */} + {pinIndicator} + {props.projectDisplayName ? ( + + {props.projectDisplayName} + + ) : ( + + )} + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {topStatus ? ( + isWokeStatus ? ( + + + + {topStatus.label} + + } + /> + Dismiss Woke notification + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "waiting" ? ( + + ) : topStatus.icon === "input" ? ( + + ) : topStatus.icon === "approval" ? ( + + ) : topStatus.icon === "failed" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : status === "waiting" ? ( + + + + ) : null} + + ) + ) : ( + + )} + + {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + + {hasUnsentDraft ? ( + + + } + > + + + Discard draft + + ) : null} + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + + } + > + + Settle + + Settle thread + + ) : null} + + ) : null} + + )} + {props.sweepAction !== null ? dragDestination : null} +
    +
    + {title} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} +
    +
    + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + + {props.scratchMachineLabel} + + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + + +{formatDiffCount(diff.insertions)} + {" "} + + −{formatDiffCount(diff.deletions)} + + + ) : null} + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + + +
    +
    + {props.jumpLabel ? : null} +
    + {detailsTooltip} +
    +
  • + ); +}); + +function latestRunDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + void thread; + return null; +} + +export const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { + isSettled: boolean; + thread: SidebarThreadSummary; + project: EnvironmentProject | null; + projectDisplayName: string | null; + environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; + providerEntryByInstanceId: ReadonlyMap; + isHighlighted: boolean; + isRouteActive: boolean; + resultId: string; + searchMatch: EnvironmentThreadSearchMatch | null; + searchQuery: string; + onHighlight: () => void; + onSelect: () => void; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; +}) { + const { thread } = props; + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: null, + projectDisplayName: props.projectDisplayName, + isActive: props.isRouteActive, + }); + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( + props.isHighlighted || props.isRouteActive, + ); + const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd, includeRemote: false }, + }) + : null, + ); + const visibleGitStatus = useRetainedValue( + JSON.stringify([thread.environmentId, gitCwd]), + gitStatus.data, + ); + const branchMismatch = resolveLocalCheckoutBranchMismatch({ + effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", + activeWorktreePath: thread.worktreePath, + activeThreadBranch: thread.branch, + currentGitBranch: visibleGitStatus?.refName ?? null, + }); + const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const onFileDropThreads = props.onFileDropThreads; + const fileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + addFolders: () => {}, + }); + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); + return ( +
  • + + + } + > + {props.project ? ( + + ) : null} + + + + {thread.title} + + + + + + + + + {props.searchMatch ? ( + + ) : null} + + + + +
  • + ); +}); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx deleted file mode 100644 index d3c6f368a54d..000000000000 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - handleSidebarUpdateReleaseNotesPopoverOpenChange, - openSidebarUpdateReleaseNotesPopoverOnForwardTab, - shouldUseSidebarUpdateReleaseNotesPopover, -} from "./SidebarUpdatePill"; - -const nightlyState: DesktopUpdateState = { - enabled: true, - status: "available", - channel: "nightly", - currentVersion: "0.0.35", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: "0.0.36-nightly.3", - downloadedVersion: null, - releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("sidebar update release notes popover", () => { - it("uses the popover only for visible nightly release notes", () => { - expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true); - expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false); - expect( - shouldUseSidebarUpdateReleaseNotesPopover(true, { - ...nightlyState, - channel: "latest", - }), - ).toBe(false); - expect( - shouldUseSidebarUpdateReleaseNotesPopover(true, { - ...nightlyState, - releaseNotes: [], - }), - ).toBe(false); - }); - - it("cancels trigger presses without canceling other open reasons", () => { - const cancelTriggerPress = vi.fn(); - const cancelHover = vi.fn(); - - handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { - reason: "trigger-press", - cancel: cancelTriggerPress, - }); - handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { - reason: "trigger-hover", - cancel: cancelHover, - }); - - expect(cancelTriggerPress).toHaveBeenCalledOnce(); - expect(cancelHover).not.toHaveBeenCalled(); - }); - - it("promotes forward Tab without preventing native navigation", () => { - const open = vi.fn(); - const preventDefault = vi.fn(); - const event = { key: "Tab", shiftKey: false, preventDefault }; - - openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes"); - - expect(open).toHaveBeenCalledWith("nightly-release-notes"); - expect(preventDefault).not.toHaveBeenCalled(); - }); - - it("does not promote backward Tab", () => { - const open = vi.fn(); - - openSidebarUpdateReleaseNotesPopoverOnForwardTab( - { key: "Tab", shiftKey: true }, - { open }, - "nightly-release-notes", - ); - - expect(open).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index b7b329de37c9..68b953708421 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -49,21 +49,21 @@ type SidebarUpdatePopoverChangeDetails = Parameters< >[1]; type SidebarUpdatePopoverHandle = ReturnType; -export function shouldUseSidebarUpdateReleaseNotesPopover( +function shouldUseSidebarUpdateReleaseNotesPopover( showUpdateDetails: boolean, state: DesktopUpdateState | null, ): boolean { return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; } -export function handleSidebarUpdateReleaseNotesPopoverOpenChange( +function handleSidebarUpdateReleaseNotesPopoverOpenChange( _open: boolean, details: Pick, ): void { if (details.reason === "trigger-press") details.cancel(); } -export function openSidebarUpdateReleaseNotesPopoverOnForwardTab( +function openSidebarUpdateReleaseNotesPopoverOnForwardTab( event: { readonly key: string; readonly shiftKey: boolean }, handle: Pick, triggerId: string, diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx deleted file mode 100644 index 540eff3c6655..000000000000 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; -import { isValidElement, type MouseEvent, type ReactElement, type ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - addToast: vi.fn(), -})); - -vi.mock("../ui/toast", () => ({ - toastManager: { add: testState.addToast }, -})); - -import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; - -type AnchorElement = ReactElement<{ - readonly children?: ReactNode; - readonly href?: string; - readonly onClick?: (event: MouseEvent) => void; -}>; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "available", - channel: "nightly", - currentVersion: "0.0.35", - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - availableVersion: "0.0.36-nightly.3", - downloadedVersion: null, - releaseNotes: [], - omittedReleaseCount: 0, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -function collectAnchors(node: ReactNode, anchors: AnchorElement[] = []): AnchorElement[] { - if (Array.isArray(node)) { - for (const child of node) collectAnchors(child, anchors); - return anchors; - } - if (!isValidElement(node)) return anchors; - - const element = node as ReactElement<{ readonly children?: ReactNode }>; - if (typeof element.type === "function") { - const render = element.type as (props: unknown) => ReactNode; - return collectAnchors(render(element.props), anchors); - } - if (element.type === "a") anchors.push(element as AnchorElement); - return collectAnchors(element.props.children, anchors); -} - -function textContent(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") return String(node); - if (Array.isArray(node)) return node.map(textContent).join(""); - if (!isValidElement(node)) return ""; - const element = node as ReactElement<{ readonly children?: ReactNode }>; - return textContent(element.props.children); -} - -function renderNotes(state: DesktopUpdateState, openExternal = vi.fn().mockResolvedValue(true)) { - return SidebarUpdateReleaseNotes({ - shell: { openExternal }, - state, - tooltip: "Update available", - }); -} - -describe("SidebarUpdateReleaseNotes", () => { - beforeEach(() => { - testState.addToast.mockReset(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("uses the configured repository for release links", () => { - vi.stubEnv("T3CODE_DESKTOP_UPDATE_REPOSITORY", "JustYannicc/t3code"); - const anchors = collectAnchors( - renderNotes({ - ...baseState, - releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], - omittedReleaseCount: 1, - }), - ); - - expect(anchors.map(({ props }) => props.href)).toEqual([ - "https://github.com/JustYannicc/t3code/releases/tag/v0.0.36-nightly.3", - "https://github.com/JustYannicc/t3code/releases", - ]); - }); - - it("links each preview to its exact release and labels hidden changes", () => { - const anchors = collectAnchors( - renderNotes({ - ...baseState, - releaseNotes: [ - { version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }, - { version: "0.0.36-nightly.2", items: ["Change 2"], totalItems: 2 }, - { version: "0.0.36-nightly.1", items: ["Change 1", "Earlier"], totalItems: 4 }, - ], - }), - ); - - expect(anchors.map(({ props }) => props.href)).toEqual([ - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.2", - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.1", - ]); - expect(anchors.map(({ props }) => textContent(props.children))).toEqual([ - "View release on GitHub", - "1 more change on GitHub", - "2 more changes on GitHub", - ]); - }); - - it("links omitted releases to release history", () => { - const anchors = collectAnchors( - renderNotes({ - ...baseState, - releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], - omittedReleaseCount: 1, - }), - ); - - expect(anchors.at(-1)?.props.href).toBe("https://github.com/pingdotgg/t3code/releases"); - expect(textContent(anchors.at(-1)?.props.children)).toBe("1 older release on GitHub"); - }); - - it("shows plural history text for multiple omitted releases", () => { - const anchors = collectAnchors( - renderNotes({ - ...baseState, - releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], - omittedReleaseCount: 3, - }), - ); - - expect(textContent(anchors.at(-1)?.props.children)).toBe("3 older releases on GitHub"); - }); - - it("reports a release link that fails to open", async () => { - const openExternal = vi.fn().mockResolvedValue(false); - const [anchor] = collectAnchors( - renderNotes( - { - ...baseState, - releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Change 3"], totalItems: 1 }], - }, - openExternal, - ), - ); - const preventDefault = vi.fn(); - - anchor?.props.onClick?.({ preventDefault } as unknown as MouseEvent); - - expect(preventDefault).toHaveBeenCalledOnce(); - await vi.waitFor(() => { - expect(openExternal).toHaveBeenCalledWith( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.36-nightly.3", - ); - expect(testState.addToast).toHaveBeenCalledWith({ - type: "error", - title: "Unable to open release notes", - }); - }); - }); -}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index c47421d73406..2bcfd7530b0d 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -4,38 +4,10 @@ import { describe, expect, it } from "vite-plus/test"; import { collectRestartingMachines, collectServerUpdateTargets, - closeFinishedUpdateRun, - collectUpdateEverythingResult, - describeUpdateCapsule, - describeUpdateEverything, - getUpdateEverythingToastView, - resolveUpdateRunMachineStatus, runUpdateEverything, type UpdateEverythingMachine, - type UpdateEverythingRun, - type UpdateRunMachine, - type UpdateRunMachineStatus, - UPDATE_SLOW_AFTER_MS, } from "./updateEverything.logic"; -const runMachine = (overrides: Partial = {}): UpdateRunMachine => ({ - environmentId: EnvironmentId.make("alvin"), - label: "alvin", - updatesProviders: true, - server: { - environmentId: EnvironmentId.make("alvin"), - serverLabel: "alvin", - selfUpdate: "boot-service", - targetVersion: "0.0.50", - }, - handedOffVersion: null, - startedAt: 0, - result: null, - failure: null, - providerFailure: null, - ...overrides, -}); - function machine( id: string, serverVersion: string, @@ -154,32 +126,6 @@ describe("collectRestartingMachines", () => { }); describe("runUpdateEverything", () => { - it("restarts servers after providers finish and returns every result", async () => { - const calls: string[] = []; - let finishProviders = (_value: string) => {}; - const run = runUpdateEverything({ - downloadLocal: async () => { - calls.push("download"); - return true; - }, - updateProviders: () => - new Promise((resolve) => { - calls.push("providers"); - finishProviders = resolve; - }), - updateServers: async () => { - calls.push("servers"); - return 2; - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls).toEqual(["download", "providers"]); - finishProviders("provider results"); - expect(await run).toEqual({ localReady: true, providers: "provider results", servers: 2 }); - expect(calls).toEqual(["download", "providers", "servers"]); - }); - it("still updates remote machines when the local download fails", async () => { const calls: string[] = []; const result = await runUpdateEverything({ @@ -196,233 +142,3 @@ describe("runUpdateEverything", () => { expect(result.localReady).toBe(false); }); }); - -describe("resolveUpdateRunMachineStatus", () => { - const running = (stage: "downloading" | "installing" | "resuming") => - ({ status: "running", stage, fromVersion: "0.0.40", targetVersion: "0.0.50" }) as const; - - it("follows the run phase, then the server's own update stage", () => { - const idle = { status: "idle" } as const; - expect(resolveUpdateRunMachineStatus(runMachine(), "providers", idle)).toBe("providers"); - expect(resolveUpdateRunMachineStatus(runMachine(), "servers", idle)).toBe("waiting"); - expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("installing"))).toBe( - "downloading", - ); - expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("resuming"))).toBe( - "restarting", - ); - expect( - resolveUpdateRunMachineStatus( - runMachine({ updatesProviders: false }), - "providers", - running("downloading"), - ), - ).toBe("downloading"); - expect(resolveUpdateRunMachineStatus(runMachine({ result: "failed" }), "servers", idle)).toBe( - "failed", - ); - expect( - resolveUpdateRunMachineStatus( - runMachine({ server: null, handedOffVersion: "0.0.50" }), - "servers", - idle, - ), - ).toBe("restarting"); - }); -}); - -describe("update capsule", () => { - const run = (overrides: Partial = {}): UpdateEverythingRun => ({ - localVersion: null, - phase: "servers", - restartAt: null, - machines: [], - ...overrides, - }); - const view = ( - label: string, - status: UpdateRunMachineStatus, - overrides: Partial = {}, - ) => ({ - machine: runMachine({ environmentId: EnvironmentId.make(label), label, ...overrides }), - status, - }); - - it("counts down this app's restart before anything else", () => { - expect( - describeUpdateCapsule( - run({ phase: "countdown", restartAt: 5_000, localVersion: "0.0.50" }), - [view("alvin", "restarting")], - { status: "waiting" }, - 1_200, - ), - ).toEqual({ tone: "default", label: "Restart in 4s", action: "later" }); - }); - - it("names the slowest machine once it passes the slow threshold", () => { - const machines = [ - view("bb-1", "done"), - view("zbook", "downloading", { startedAt: 60_000 }), - view("alvin", "restarting", { startedAt: 0 }), - ]; - expect(describeUpdateCapsule(run(), machines, null, 60_000)).toEqual({ - tone: "default", - label: "1 of 3 updated", - action: null, - }); - expect(describeUpdateCapsule(run(), machines, null, UPDATE_SLOW_AFTER_MS + 6 * 60_000)).toEqual( - { - tone: "slow", - label: "alvin", - slow: { status: "restarting", elapsed: "9m" }, - action: null, - }, - ); - }); - - it("offers a retry only for failed servers it can update again", () => { - expect(describeUpdateCapsule(run(), [view("alvin", "failed")], null, 0)).toEqual({ - tone: "failed", - label: "alvin failed", - action: "retry", - }); - expect( - describeUpdateCapsule( - run(), - [view("alvin", "failed", { server: null }), view("box", "failed", { server: null })], - null, - 0, - ), - ).toEqual({ tone: "failed", label: "2 failed", action: "dismiss" }); - }); - - it("ends the run only when every machine is done and this app is not installing", () => { - const done = runMachine({ result: "done" }); - expect(closeFinishedUpdateRun(run({ machines: [done] }))).toBeNull(); - expect( - closeFinishedUpdateRun(run({ machines: [done], localVersion: "0.0.50" })), - ).not.toBeNull(); - expect( - closeFinishedUpdateRun(run({ machines: [runMachine({ result: "failed" })] })), - ).not.toBeNull(); - expect(closeFinishedUpdateRun(run({ machines: [done], phase: "providers" }))).not.toBeNull(); - }); -}); - -describe("update everything summary", () => { - const result = collectUpdateEverythingResult({ - localVersion: "0.0.50", - localFailures: ["Could not download update: Network error."], - servers: [ - { label: "alvin", outcome: { status: "failed", message: "Download failed." } }, - { label: "box", outcome: { status: "updated" } }, - { label: "studio", outcome: { status: "updated" } }, - { label: "busy", outcome: { status: "skipped" } }, - ], - providers: [ - { status: "updated" }, - { status: "interrupted" }, - { status: "failed", line: "box · Codex: npm exited with code 1." }, - ], - }); - - it("counts updates and names the machine of each failure", () => { - expect(result).toEqual({ - localVersion: "0.0.50", - updatedServers: 2, - updatedProviders: 1, - restarting: [], - failures: [ - "This app: Could not download update: Network error.", - "alvin: Download failed.", - "box · Codex: npm exited with code 1.", - ], - }); - }); - - it("includes this app only when the relaunch landed on its version", () => { - expect(getUpdateEverythingToastView(result, "0.0.50")).toEqual({ - type: "error", - title: "Updated this app, 2 servers, and 1 provider", - description: [ - "This app: Could not download update: Network error.", - "alvin: Download failed.", - "box · Codex: npm exited with code 1.", - ].join("\n"), - }); - expect(getUpdateEverythingToastView({ ...result, failures: [] }, "0.0.40")).toEqual({ - type: "success", - title: "Updated 2 servers and 1 provider", - }); - }); - - it("reports a failure when nothing updated, and nothing when nothing finished", () => { - const empty = { localVersion: null, updatedServers: 0, updatedProviders: 0, restarting: [] }; - expect( - getUpdateEverythingToastView({ ...empty, failures: ["alvin: offline"] }, "0.0.50"), - ).toEqual({ type: "error", title: "Update failed", description: "alvin: offline" }); - expect(getUpdateEverythingToastView({ ...empty, failures: [] }, "0.0.50")).toBeNull(); - }); - - it("names servers still restarting when this app relaunched", () => { - expect( - getUpdateEverythingToastView( - { ...result, failures: [], restarting: ["alvin", "zbook"] }, - "0.0.50", - ), - ).toEqual({ - type: "success", - title: "Updated this app, 2 servers, and 1 provider", - description: "alvin and zbook are still restarting.", - }); - }); -}); - -describe("describeUpdateEverything", () => { - it("summarizes the run and lists each machine once", () => { - const alvin = EnvironmentId.make("alvin"); - expect( - describeUpdateEverything({ - localVersion: "0.0.50", - servers: [ - { - environmentId: alvin, - serverLabel: "alvin", - selfUpdate: "boot-service", - targetVersion: "0.0.50", - }, - { - environmentId: EnvironmentId.make("studio"), - serverLabel: "studio", - selfUpdate: "desktop-managed", - targetVersion: "0.0.50", - }, - ], - providerMachines: [ - { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] }, - { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] }, - ], - }), - ).toEqual({ - summary: "Update this app, 2 servers, and 3 providers", - lines: [ - "This app: 0.0.50, restarts last", - "alvin: server to 0.0.50, Codex, Claude", - "studio: desktop app update", - "box: Cursor", - ], - }); - }); - - it("reads naturally with one kind of update", () => { - expect( - describeUpdateEverything({ - localVersion: null, - servers: [], - providerMachines: [ - { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] }, - ], - }).summary, - ).toBe("Update 1 provider"); - }); -}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 9a7c32ab4497..3e31dc5fbf50 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -206,7 +206,7 @@ export function closeFinishedUpdateRun(run: UpdateEverythingRun): UpdateEverythi : run; } -export const UPDATE_SLOW_AFTER_MS = 3 * 60 * 1000; +const UPDATE_SLOW_AFTER_MS = 3 * 60 * 1000; export function formatUpdateElapsed(ms: number): string { const minutes = Math.floor(ms / 60_000); diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts deleted file mode 100644 index 41f6b97c9283..000000000000 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - clampThreadSidebarWidth, - resolveThreadSidebarMaximumWidth, - resolveThreadSidebarMinimumWidth, - THREAD_SIDEBAR_MIN_WIDTH, -} from "./threadSidebarWidth"; - -describe("resolveThreadSidebarMinimumWidth", () => { - it("keeps the default minimum when the brand fits", () => { - expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - }); - - it("grows to a brand wider than the default, rounding up", () => { - expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238); - }); -}); - -describe("resolveThreadSidebarMaximumWidth", () => { - it("never drops below a raised minimum on a narrow viewport", () => { - expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238); - expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560); - }); -}); - -describe("clampThreadSidebarWidth", () => { - it("widens a stored width below a raised minimum", () => { - expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238); - }); - - it("keeps widths inside the range and caps wide ones", () => { - expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300); - expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560); - }); -}); diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index 9dcd7c18ceda..0f55aa8593da 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -1,6 +1,6 @@ export const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; -export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index ee086887557f..e86217f7a8ec 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; import { cn } from "~/lib/utils"; @@ -8,8 +6,15 @@ import { DIALOG_MOBILE_SHEET_CLASS, DIALOG_POPUP_CLASS, } from "~/components/ui/dialog-styles"; +import { ControlledOpenContext, renderRetainedPopup } from "~/components/ui/dialog"; -const AlertDialog = AlertDialogPrimitive.Root; +function AlertDialog(props: AlertDialogPrimitive.Root.Props) { + return ( + + + + ); +} const AlertDialogPortal = AlertDialogPrimitive.Portal; @@ -60,6 +65,7 @@ function AlertDialogPopup({ className, )} data-slot="alert-dialog-popup" + render={renderRetainedPopup} {...props} /> diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 548efd944897..b4b8ac87f650 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"; import { ChevronsUpDownIcon, XIcon } from "lucide-react"; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index bfed6864c8cb..9beb9cd59f6d 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -1,5 +1,3 @@ -"use client"; - import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 582a6e9856cb..e9861fdf57f3 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,5 +1,3 @@ -"use client"; - import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 049ff745de87..226cdda24949 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { DayPicker } from "@daypicker/react"; import { ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon } from "lucide-react"; import type * as React from "react"; diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index f3f6a039aa88..cc240d6c18bc 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; import { cn } from "~/lib/utils"; diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index f33478f8c1e3..762416976c23 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"; import { cn } from "~/lib/utils"; diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 839b1e7802fb..eadce1db766d 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react"; import * as React from "react"; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index b9e3297d41aa..ca37bc1213fe 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Dialog as CommandDialogPrimitive } from "@base-ui/react/dialog"; import { SearchIcon } from "lucide-react"; import type * as React from "react"; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 11b27495657c..656a8c0a9f2d 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,7 +1,6 @@ -"use client"; - import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; +import { type ComponentProps, createContext, type ReactNode, use, useState } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { @@ -13,7 +12,33 @@ import { } from "~/components/ui/dialog-styles"; import { ScrollArea } from "~/components/ui/scroll-area"; -const Dialog = DialogPrimitive.Root; +const ControlledOpenContext = createContext(undefined); + +function RetainedWhileClosed({ open, children }: { open: boolean; children: ReactNode }) { + const controlledOpen = use(ControlledOpenContext); + const isOpen = open && controlledOpen !== false; + const [retained, setRetained] = useState(children); + if (isOpen && retained !== children) { + setRetained(children); + } + return isOpen ? children : retained; +} + +function renderRetainedPopup(props: ComponentProps<"div">, state: { open: boolean }) { + return ( +
    + {props.children} +
    + ); +} + +function Dialog(props: DialogPrimitive.Root.Props) { + return ( + + + + ); +} const DialogPortal = DialogPrimitive.Portal; @@ -88,6 +113,7 @@ function DialogPopup({ className, )} data-slot="dialog-popup" + render={renderRetainedPopup} {...props} > {children} @@ -180,6 +206,8 @@ function DialogPanel({ } export { + ControlledOpenContext, + renderRetainedPopup, Dialog, DialogTrigger, DialogPortal, diff --git a/apps/web/src/components/ui/draft-input.tsx b/apps/web/src/components/ui/draft-input.tsx index c937b3ec1f74..8746ff3e1bff 100644 --- a/apps/web/src/components/ui/draft-input.tsx +++ b/apps/web/src/components/ui/draft-input.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCommitOnBlur } from "~/hooks/useCommitOnBlur"; import { Input, type InputProps } from "./input"; diff --git a/apps/web/src/components/ui/group.tsx b/apps/web/src/components/ui/group.tsx index 75d8fbe786aa..c6548131214c 100644 --- a/apps/web/src/components/ui/group.tsx +++ b/apps/web/src/components/ui/group.tsx @@ -1,4 +1,3 @@ -"use client"; import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index f1829c3945c9..1e41a8b87871 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -1,5 +1,3 @@ -"use client"; - import { cva, type VariantProps } from "class-variance-authority"; import type * as React from "react"; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 6c483c318580..3c8f4fa96fb5 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Input as InputPrimitive } from "@base-ui/react/input"; import type * as React from "react"; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index d61934025a1a..b83f4523d6a0 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -1,5 +1,3 @@ -"use client"; - import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 7c944c931021..f7216f0bc5b6 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { CheckIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; import { useState } from "react"; @@ -29,6 +27,7 @@ function MenuPopup({ side = "bottom", anchor, keepMounted = false, + width = "default", ...props }: MenuPrimitive.Popup.Props & { align?: MenuPrimitive.Positioner.Props["align"]; @@ -37,6 +36,7 @@ function MenuPopup({ side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; keepMounted?: boolean; + width?: "default" | "content"; }) { return ( @@ -56,7 +56,8 @@ function MenuPopup({ // the Review panel header). Drag hit-testing ignores z-index, so // the topmost row would stay unhoverable without this opt-out. "[-webkit-app-region:no-drag]", - "min-w-[min(10rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)]", + "max-w-[calc(100vw-2rem)]", + width === "content" ? "w-max min-w-0" : "min-w-[min(10rem,calc(100vw-2rem))]", className, )} data-slot="menu-popup" diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index f516ff280121..fa563e1b9365 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,5 +1,3 @@ -"use client"; - import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"; import { MinusIcon, PlusIcon } from "lucide-react"; import * as React from "react"; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index ba521b15c6e5..0c7090bdf7ca 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Popover as PopoverPrimitive } from "@base-ui/react/popover"; import { useRef } from "react"; diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx index a077af0c9f29..d923f8856485 100644 --- a/apps/web/src/components/ui/radio-group.tsx +++ b/apps/web/src/components/ui/radio-group.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 2705b948e91a..ab31b7ca31d8 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; import { cn } from "~/lib/utils"; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index b532c8b70cd9..64cbc4f1ca42 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -1,4 +1,3 @@ -"use client"; import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index ce4a7b21a8a5..d116065b3fdd 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -1,12 +1,17 @@ -"use client"; - import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; import type { CSSProperties } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; +import { ControlledOpenContext, renderRetainedPopup } from "~/components/ui/dialog"; -const Sheet = SheetPrimitive.Root; +function Sheet(props: SheetPrimitive.Root.Props) { + return ( + + + + ); +} const SheetPortal = SheetPrimitive.Portal; @@ -101,6 +106,7 @@ function SheetPopup({ className, )} data-slot="sheet-popup" + render={keepMounted ? undefined : renderRetainedPopup} style={{ ...transitionStyle, ...style }} {...props} > diff --git a/apps/web/src/components/ui/sidebar.browser.test.tsx b/apps/web/src/components/ui/sidebar.browser.test.tsx deleted file mode 100644 index fe6bcbefb237..000000000000 --- a/apps/web/src/components/ui/sidebar.browser.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import "../../index.css"; - -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; - -import { page, userEvent } from "vite-plus/test/browser"; - -import { Sidebar, SidebarProvider, SidebarTrigger } from "./sidebar"; -import { resolveSidebarState } from "./sidebarState"; - -let root: Root; -let container: HTMLDivElement; - -beforeEach(async () => { - await page.viewport(1280, 900); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); -}); - -async function renderSidebar(open: boolean) { - await act(async () => { - root.render( - - - - - - - - , - ); - }); -} - -function element(selector: string) { - const node = container.querySelector(selector); - if (!node) throw new Error(`Missing element: ${selector}`); - return node; -} - -describe("responsive sidebar state", () => { - it("uses mobile sheet visibility for the shared responsive state", () => { - expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( - "collapsed", - ); - expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); - expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( - "expanded", - ); - }); -}); - -describe("desktop sidebar focus", () => { - it("moves focus to the external toggle on collapse and restores sidebar access on expansion", async () => { - await renderSidebar(true); - const control = element('[data-testid="sidebar-control"]'); - const sidebar = element('[data-slot="sidebar"]'); - control.focus(); - expect(document.activeElement).toBe(control); - - await renderSidebar(false); - - expect(document.activeElement).toBe(element('[data-testid="external-trigger"]')); - expect(sidebar.inert).toBe(true); - control.focus(); - expect(document.activeElement).toBe(element('[data-testid="external-trigger"]')); - await userEvent.keyboard("{Shift>}{Tab}{/Shift}"); - expect(document.activeElement).not.toBe(element('[data-testid="internal-trigger"]')); - expect(document.activeElement).not.toBe(control); - await userEvent.keyboard("{Tab}"); - expect(document.activeElement).toBe(element('[data-testid="external-trigger"]')); - await userEvent.keyboard("{Tab}"); - expect(document.activeElement).toBe(element('[data-testid="content-control"]')); - - await renderSidebar(true); - - expect(sidebar.inert).toBe(false); - control.focus(); - expect(document.activeElement).toBe(control); - }); - - it("keeps focus in the content when the sidebar collapses", async () => { - await renderSidebar(true); - const control = element('[data-testid="content-control"]'); - control.focus(); - expect(document.activeElement).toBe(control); - - await renderSidebar(false); - - expect(document.activeElement).toBe(control); - }); -}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 3e44922a7b9b..570e8299694f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -17,7 +17,7 @@ import { } from "~/components/ui/sheet"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useResizeDrag } from "~/hooks/useResizeDrag"; -import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; @@ -96,7 +96,6 @@ function useSidebarVisibility() { function SidebarProvider({ defaultOpen = true, - mobileBreakpoint, open: openProp, onOpenChange: setOpenProp, className, @@ -105,17 +104,11 @@ function SidebarProvider({ ...props }: React.ComponentProps<"div"> & { defaultOpen?: boolean; - mobileBreakpoint?: number; open?: boolean; onOpenChange?: (open: boolean) => void; }) { - const isMobile = useMediaQuery( - mobileBreakpoint === undefined ? "max-md" : { max: mobileBreakpoint }, - ); + const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); - if (!isMobile && openMobile) { - setOpenMobile(false); - } const [mobileSheetMounted, setMobileSheetMounted] = React.useState(false); const setMobileSheetElement = (element: HTMLDivElement | null) => { setMobileSheetMounted(element !== null); @@ -165,7 +158,6 @@ function SidebarProvider({ SIDEBAR_TITLEBAR_CONTROL_SIZE, className, )} - data-sidebar-mobile={isMobile} data-sidebar-state={state} data-slot="sidebar-wrapper" style={ @@ -306,7 +298,7 @@ function Sidebar({